Skip to content

WNE-41: derive and supply the Ethereum outpost's genesis batch-operator roster - #87

Open
brianjohnson5972 wants to merge 4 commits into
masterfrom
fix/wne-41_corrected2
Open

WNE-41: derive and supply the Ethereum outpost's genesis batch-operator roster#87
brianjohnson5972 wants to merge 4 commits into
masterfrom
fix/wne-41_corrected2

Conversation

@brianjohnson5972

Copy link
Copy Markdown

fix(harness): WNE-41 — derive and supply the Ethereum outpost's genesis batch-operator roster

Repo: wire-tools-ts · Branch: fix/wne-41_corrected2 · Base: master
Companion PR: wire-ethereumwne-41_corrected2 (must land together)


Why this is needed

wire-ethereum's WNE-41 fix makes OPPInbound.isActiveOperator fail-closed and moves the
batch-operator roster into initialize. On a WIRE cluster the addresses that actually send epochIn
are the batch-operator daemons' own EOAs, not the deployer — so without this change the outpost
deploys with a roster that authorizes nobody who matters, the first envelope is refused, and the
epoch never advances.

What it does

EthereumOutpostSteps.resolveGenesisRoster derives the roster and threads it through
EthereumOutpostBootstrapper into the outpost.json deploy config that deployLocal.ts consumes.

The addresses are derived, not read off provisioned operators, because of phase ordering: the
outpost deploys in Cluster Prerequisites (EthereumOutpost), while batch operators are provisioned
later in Cluster Post Contract Deployment. The (mnemonic, HD index) pair is fully determined by
then, and both sides read it from the same two authorities
KeySteps.ethereumMnemonic(ctx) and Constants.batchOperatorEthereumHdIndex(index) — so the roster
cannot disagree with the keys the daemons later sign with.

Grouping is sized by the depot's own operatorsPerEpoch from BatchOperatorSchedule.resolve,
because batchOpGroups[0].length is the outpost's consensus threshold. Membership in later
groups grants delivery rights without raising it. The depot replaces the whole roster on its first
BATCH_OPERATOR_GROUPS attestation.

Changes

File Change
Constants.ts batchOperatorEthereumHdIndex / underwriterEthereumHdIndex — the HD-index rule now has one home instead of being spelled inline
ClusterBuildDefaults.ts consumes those instead of open-coding index + 1 / batchOperatorCount + index + 1
EthereumOutpostSteps.ts resolveGenesisRoster + wiring into the deploy step
EthereumOutpostBootstrapper.ts required genesisRoster option, fail-fast asserts, writes genesisOperatorGroups + epochDurationSec into the deploy config, logs the roster

The HD-index extraction is the load-bearing detail: the roster is derived at a point in the
bootstrap where operator accounts do not yet exist, so a second spelling of that rule would silently
authorize the wrong addresses — and fail closed, minutes later, as a stalled epoch.

Fail-fast

EthereumOutpostBootstrapper's constructor rejects a roster with no operators, or a non-positive
epochDurationSec, before anvil is touched. OPPInbound.initialize is one-shot with no roster
setter, so a bad roster produces an outpost recoverable only by a UUPS implementation upgrade —
failing at construction is strictly cheaper.

Duplicate and empty-group rejection lives in the contract and the wire-ethereum TS builder (see
the companion PR). The harness derivation cannot produce either: chunk() never emits an empty group
for non-empty input, and distinct HD indices give distinct addresses. No redundant check added here.

Scope

  • Local mode only. External-outpost mode replaces the deploy phases with
    MaterializeExternalOutposts, so planDeploy never runs and no roster is derived.
  • Solana untouched. The SVM-side equivalent (SOL-376/381) landed separately in wire-solana.

Testing

  • pnpm --filter @wireio/cluster-tool test: 1410 passing, 0 failing. pnpm build and
    pnpm run lint clean.
  • EthereumOutpostSteps.test.ts pins the derivation: every batch operator seated at the address its
    own EM key derives to (verified against an independent ethers.HDNodeWallet derivation, not by
    re-running the code under test), group 0 sized to operatorsPerEpoch, later-group members still
    admitted, and the deploy owner excluded — deployment privilege is not delivery privilege.
  • EthereumOutpostBootstrapper.test.ts covers the new required option and both fail-fast rejections.
  • Live: flow-operator-collateral-deposit SUCCEEDED — 140/140 steps, epochs 1→6, all four
    OPP directions circulating, 0 OPP_NotActiveOperator in the cluster log while 11 real epochIn
    deliveries from batch-operator EOAs landed. The deploy config written by this branch carried
    [[0x7099…], [0x3C44…], [0x90F7…]] with epochDurationSec: 60 — three distinct addresses, no
    empty group, deployer absent.

Zero OPP_NotActiveOperator is the decisive number: it is the exact revert a wrong derivation
produces, and the fail-closed gate means a mismatch could not pass silently.


🤖 Generated with Claude Code

@brianjohnson5972

Copy link
Copy Markdown
Author

@brianjohnson5972

brianjohnson5972 commented Aug 28, 2026

Copy link
Copy Markdown
Author

E2E flow tests dispatchedhttps://github.com/Wire-Network/wire-platform-build-system/actions/runs/33407253427

Branch combination under test (everything else floats to the manifest defaults):

Repo Ref
wire-tools-ts fix/wne-41_corrected2 (#87)
wire-ethereum wne-41_corrected2 (Wire-Network/wire-ethereum#194)

brianjohnson5972 and others added 2 commits August 28, 2026 10:59
…or roster

Change-Id: I4aa55e1b880e08a2fc80e8fb4d3de3ec286928dd
wire-sysio PR #578 ("fix/drop-daemon-enable-flags") REMOVED both options;
enablement now derives from the configured account, the way producer_plugin
keys off producer-name. The harness kept passing them, and nodeop rejects
unknown options outright, so every operator daemon failed to start: no batch
operator delivered, no OPP circulated, and every flow died in bootstrap.

e2e 33105055065 was 0/14 with exactly that signature — all fourteen flows
failing fast and identically, while the platform build itself passed. Neither
WNE-41 branch touches daemon flags; the break came in when the run floated
wire-sysio to master.

`--batch-operator-account` / `--underwriter-account` were already being passed
on the following lines, so the new enable mechanism was satisfied all along.

The two assertions that required the flags are inverted to `toEqual([])`, so
reintroducing either fails the suite instead of silently disabling every
daemon. Three arbitrary extraArgs fixtures naming --batch-enabled are swapped
for a live option so they stop reading as documentation of a real flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Change-Id: I39644a49882640a8f21af2314e12f8b72944d7b6
…test

The master merge kept both sides' `extraArgs` property on the
non-producing-node case. The first resolution left them without a
separator (TS1005 at 195,7, which took the e2e build down); the second
added the comma but left two properties with the same name — TS1117,
which fires even under `strict: false`, and which would have dropped
`--batch-operator-account` from the literal and failed the
`arrayContaining` assertion below it.

Both sides' flags belong in one array; the assertion already expects
all four entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Change-Id: I13ce5a4a9db53468c73b05e1e3ea091a1561f27d

@jglanz jglanz 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.

Rename genesis -> initial EVERYWHERE, genesis already has a discreet meaning

Comment on lines +322 to +339
export function batchOperatorEthereumHdIndex(index: number): number {
return index + 1
}

/**
* HD account index for an underwriter's EM key — continues past the batch
* operators. See {@link batchOperatorEthereumHdIndex}.
*
* @param batchOperatorCount - Size of the batch-operator roster.
* @param index - Zero-based underwriter index.
* @returns The HD account index.
*/
export function underwriterEthereumHdIndex(
batchOperatorCount: number,
index: number
): number {
return batchOperatorCount + index + 1
}

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.

both of these should use the label prefix filtered - the index is guaranteed

Comment on lines +194 to +199
extraArgs: [
"--batch-operator-account",
"wireno.batchopaaaa",
"--batch-epoch-poll-ms",
"500"
]

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.

remove change, its unrelated and requires clear reasoning

Comment on lines +208 to +210
"wireno.batchopaaaa",
"--batch-epoch-poll-ms",
"500"

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.

same as previous

const relaunchNode = node("relaunch", NodeRole.producer, ["sysio"]),
operator = producerOperator("sysio"),
extraArgs = ["--batch-enabled", "true"]
extraArgs = ["--batch-epoch-poll-ms", "500"]

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.

same as previous, also, I believe --batch-enabled is removed in master

/** A valid WNE-41 genesis roster — one operator, one positive duration. */
genesisRoster: EthereumOutpostGenesisRoster = {
groups: [[AnvilAccount0Address]],
epochDurationSec: 60

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.

Magic numbers are not aloud, and i believe there is a global constant elsewhere

brianjohnson5972 added a commit that referenced this pull request Aug 31, 2026
… HD index

jglanz's review on wire-tools-ts#87:

* "Rename `genesis` -> `initial` EVERYWHERE, `genesis` already has a
  discreet meaning" — renamed the WNE-41 roster vocabulary
  (EthereumOutpostGenesisRoster -> EthereumOutpostInitialRoster,
  genesisRoster -> initialRoster, resolveGenesisRoster ->
  resolveInitialRoster) and the cross-repo deploy-config key
  genesisOperatorGroups -> initialOperatorGroups, which moves in lockstep
  with the wire-ethereum companion branch. The chain's own genesis
  vocabulary is deliberately untouched: genesisTimestamp, genesisFile, the
  bios/finalizer dev-key comments, "genesis policy" and "starts at genesis"
  are the discreet meaning the review is protecting.

* Constants.ts "both of these should use the label prefix filtered - the
  index is guaranteed" — batchOperatorEthereumHdIndex(index) and
  underwriterEthereumHdIndex(batchOperatorCount, index) are replaced by ONE
  ethereumHdIndex(label). The label's PREFIX selects the role's HD band and
  its letter gives the ordinal, so no caller supplies a roster size and the
  two roles cannot collide. The prefixes are now named constants
  (BatchOperatorLabelPrefix / UnderwriterLabelPrefix) that the label
  builders share, rather than literals spelled twice.

  Bands are sized to the label space itself (LowercaseAlphabet), which is
  what makes a label alone sufficient. Two constants follow from that and
  are derived, not re-guessed: SwapUserIdentities.DefaultEthereumHdIndex is
  now Constants.FirstNonOperatorHdIndex (was a bare 32, which the new
  underwriter band would have overlapped), and AnvilProcess.AccountCount
  tracks the same floor so every operator band stays pre-funded — its JSDoc
  already promised exactly that.

* NodeopProcess.test.ts "remove change, its unrelated" (x3) — reverted the
  file to master. Those three edits came from the unrelated
  --batch-enabled/--underwriter-enabled commit, whose only contribution to
  this branch was that file. Note --batch-enabled is still on master
  (NodeopProcess.test.ts:823, OperatorDaemonTool.test.ts:401).

* EthereumOutpostBootstrapper.test.ts "magic numbers are not aloud" —
  epochDurationSec: 60 -> ClusterConfigProvider.DefaultEpochDurationSec, at
  both fixture sites. The `0` in the non-positive-duration case stays a
  literal: it is the value under test.

Verified: eslint clean; 135/135 suites, 1415/1415 tests (node 24).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Change-Id: I21c13b7199f820180052664cad3c12ae3ac967f2
@brianjohnson5972

Copy link
Copy Markdown
Author

@jglanz jglanz 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.

Brian you missed my point regarding indexes; the index #s and sort order of the cluster keys json can not be guaranteed, so when you are searching the dataset for a batchop or a uw, you need to filter first by the label prefix and then once you have an array that only has the batchops only then can you use the index.

I'd revert the latest changes and try again with the refined explanation.

Comment on lines +216 to +218
export const NonOperatorAccountHeadroom = 8
export const AccountCount =
Constants.FirstNonOperatorHdIndex + NonOperatorAccountHeadroom

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.

AccountCount is not static, it varies by config

label,
type: OperatorType.BATCH,
ethereumHdIndex: index + 1,
ethereumHdIndex: Constants.ethereumHdIndex(label),

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.

Must use the iterator index

label,
type: OperatorType.UNDERWRITER,
ethereumHdIndex: config.batchOperatorCount + index + 1,
ethereumHdIndex: Constants.ethereumHdIndex(label),

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.

Must use the iterator index

/** HD index past every operator slot in the largest planned cluster. */
export const DefaultEthereumHdIndex = 32
/** HD index past every operator slot the floor Constants reserves for non-operators. */
export const DefaultEthereumHdIndex = Constants.FirstNonOperatorHdIndex

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 is not a constant # - driven by config

Change-Id: I13e0a6fcdbad287a553ae7d7e702a8c60326a089
@brianjohnson5972

Copy link
Copy Markdown
Author

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