Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
readNodeOwner,
readNodeOwnerReg
} from "../tools/ethereum/EthereumNodeOwnerNftTool.js"
import { EthereumOutpostManagerTool } from "../tools/ethereum/EthereumOutpostManagerTool.js"
import { AuthExLinkTool } from "../tools/all/AuthExLinkTool.js"
import { pollUntil, verifyStep } from "./StepTools.js"
import type { ClusterBuildOptions } from "../config/ClusterBuildOptions.js"
Expand Down Expand Up @@ -920,6 +921,41 @@ export namespace ClusterBuildDefaults {
]
)

// WNE-41: authorize each batch operator's Ethereum EOA to deliver the
// genesis envelope, BEFORE the operator daemons start.
//
// Until the ETH outpost installs its first batch-operator roster,
// `OPPInbound.epochIn` has no roster to authorize against and gates on the
// AccessManager instead — a role `OutpostManager.setupOPPRoles` grants to
// nobody. The sender is the operator's OWN EOA (the
// `outpost_ethereum_client` plugin signs `epochIn` with the daemon's key),
// so the deployer grant `deployLocal.ts` makes does not cover it. Without
// this phase epoch 1 reverts for every caller and the cluster epoch-stalls.
//
// It belongs HERE and not in the outpost deploy: these accounts are
// provisioned in the phase above, long after the outpost is on chain.
// External-outpost mode is excluded — that depot talks to outposts this run
// did not deploy and holds no admin authority over them; their operators are
// authorized out of band.
if (!isExternalOutpost) {
const bootstrapDeliveryPhase = ClusterBuildPhase.create<C>(
postContractDeployment,
"GrantBootstrapDelivery",
"Authorize each batch operator to deliver the ETH outpost's genesis envelope"
)
batchOperators.forEach(label =>
bootstrapDeliveryPhase.push(
EthereumOutpostManagerTool.planGrantBootstrapDelivery<C>(
Actor.EthereumOutpost,
`grant-bootstrap-delivery-${label}`,
`grant opp_inbound to ${label}`,
{},
label
)
)
)
}

// SSM mode: publish the just-provisioned operator keys BEFORE the operator
// daemons start — their wire/ethereum/solana `--signature-provider ...SSM:`
// specs fetch the private keys from SSM at nodeop startup.
Expand Down
162 changes: 162 additions & 0 deletions packages/cluster-tool/src/tools/ethereum/EthereumOutpostManagerTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* EthereumOutpostManagerTool — Step factories for `OutpostManager`'s
* AccessManager administration surface on the Ethereum outpost.
*
* `OutpostManager.grantRole(role, grantee)` is `restricted` and forwards to the
* `OutpostManagerAuthority` (an OpenZeppelin `AccessManager`); only the deploy
* owner — anvil HD index 0, which `deployLocal.ts` leaves holding ADMIN_ROLE and
* which `ctx.ethereum.wallet.signer` is bound to — may call it.
*
* The one grant this tool exists for is the WNE-41 genesis-delivery
* authorization; see {@link EthereumOutpostManagerTool.planGrantBootstrapDelivery}.
*/

import Assert from "node:assert"
import { ethers } from "ethers"

import { ClusterConfigProvider } from "../../config/ClusterConfigProvider.js"
import { ClusterBuildContext } from "../../orchestration/ClusterBuildContext.js"
import {
ClusterBuildStep,
type ClusterBuildStepOptions
} from "../../orchestration/ClusterBuildStep.js"
import type { StepInput } from "../../orchestration/StepRunner.js"
import { Report } from "../../report/Report.js"
import {
EvmAddressPattern,
loadOutpostContract,
resolveLatestNonce
} from "../../utils/ethereumUtils.js"
import { EthereumCollateralTool } from "./EthereumCollateralTool.js"

/**
* Structural surface of the `OutpostManager` members this tool binds — the
* AccessManager grant forwarder and the `epochIn` role constant it grants.
*/
export interface OutpostManagerContract extends ethers.BaseContract {
/** Forwards to `AccessManager.grantRole(role, grantee, 0)`; `restricted`. */
grantRole: (
role: bigint,
grantee: string,
overrides?: ethers.Overrides
) => Promise<ethers.ContractTransactionResponse>
/** `OutpostManagerCommon.OPP_INBOUND_ROLE` — the `epochIn` role id. */
OPP_INBOUND_ROLE: () => Promise<bigint>
getAddress: () => Promise<string>
}

export namespace EthereumOutpostManagerTool {
/** Input for {@link planGrantBootstrapDelivery} — ONE `grantRole` write. */
export interface GrantBootstrapDeliveryInput extends StepInput {
readonly kind: "EthereumOutpostManagerTool.GrantBootstrapDeliveryInput"
/** Operator's durable `label` handle — resolved from `ctx.keyStore` (NOT its on-chain `account`). */
readonly operatorLabel: string
}

/**
* Authorize ONE operator's Ethereum EOA to deliver the genesis envelope —
* `OutpostManager.grantRole(OPP_INBOUND_ROLE, <operator ETH address>)`.
*
* WNE-41: while the outpost's genesis bootstrap window is open
* (`OPPInbound.rosterInitialized == false`) there is no batch-operator roster
* to authorize against, so `epochIn` authorizes on the AccessManager instead.
* `OutpostManager.setupOPPRoles` registers the `epochIn` selector under
* `OPP_INBOUND_ROLE` and grants that role to NOBODY, so without this step
* epoch 1 reverts `AccessManagedUnauthorized` for every caller, the outpost
* never reaches consensus, the depot's `chkcons` never fires `advance`, and
* every flow dies on an epoch stall.
*
* The grantee is the operator's OWN EOA, because that is what actually sends
* the transaction: `outpost_ethereum_client_plugin`'s
* `deliver_outbound_envelope` calls `epochIn` signed with the daemon's key,
* and the gate reads `msg.sender`. `deployLocal.ts`'s deployer grant does NOT
* cover it — the deployer is anvil HD index 0 and the batch operators are HD
* index 1..N (and under an SSM signature provider their keys come off a
* generated mnemonic that has no relationship to the anvil one at all).
*
* This runs AFTER operator provisioning by necessity: the accounts do not
* exist when the outpost deploys. It is inert the moment the roster installs —
* `epochIn` stops consulting the AccessManager and the window never reopens —
* so nothing revokes it.
*
* @param actor Report actor the step is attributed to.
* @param name Step name as it appears in the Report.
* @param description Human-readable step description.
* @param options Step options (timeout, retry, …).
* @param operatorLabel Durable harness handle of the operator to authorize.
* @returns The step performing the one `grantRole` write.
*/
export function planGrantBootstrapDelivery<
C extends ClusterBuildContext = ClusterBuildContext
>(
actor: Report.Actor,
name: string,
description: string,
options: ClusterBuildStepOptions,
operatorLabel: string
): ClusterBuildStep<C, GrantBootstrapDeliveryInput> {
return ClusterBuildStep.create<C, GrantBootstrapDeliveryInput>(
actor,
name,
description,
options,
{
kind: "EthereumOutpostManagerTool.GrantBootstrapDeliveryInput",
operatorLabel
},
runGrantBootstrapDelivery
)
}

/** Named runner — ONE `OutpostManager.grantRole(...)` write, signed by the deploy owner. */
export async function runGrantBootstrapDelivery<
C extends ClusterBuildContext
>(
ctx: C,
input: GrantBootstrapDeliveryInput,
signal: AbortSignal
): Promise<void> {
signal.throwIfAborted()
const operator = ctx.keyStore.assertOperator(input.operatorLabel)
const grantee = operator.ethereum?.address
Assert.ok(
grantee != null && EvmAddressPattern.test(grantee),
`EthereumOutpostManagerTool.planGrantBootstrapDelivery: ` +
`operator ${input.operatorLabel} has no Ethereum address (got ${grantee})`
)

const manager = loadOutpostManager(ctx)
const role = await manager.OPP_INBOUND_ROLE()
const nonce = await resolveLatestNonce(manager)
const response = await manager.grantRole(role, grantee, { nonce })
const receipt = await response.wait(1)
Assert.ok(
receipt?.status === 1,
`EthereumOutpostManagerTool.planGrantBootstrapDelivery: reverted for ` +
`${input.operatorLabel} (${grantee}, status=${receipt?.status ?? "null"})`
)
}

/**
* Resolve the deployed `OutpostManager` from THIS cluster's deploy artifacts,
* bound to the deploy owner (`ctx.ethereum.wallet.signer`, anvil HD index 0) —
* the only identity holding ADMIN_ROLE on the outpost's AccessManager, and so
* the only one `grantRole`'s `restricted` modifier admits.
*
* @param ctx Build context carrying the cluster config + Ethereum client.
* @returns The owner-bound `OutpostManager` surface.
*/
export function loadOutpostManager<C extends ClusterBuildContext>(
ctx: C
): OutpostManagerContract {
return loadOutpostContract<OutpostManagerContract>(
ctx.config.ethereumPath,
EthereumCollateralTool.loadOutpostAddresses(
ClusterConfigProvider.ethereumDeploymentsPath(ctx.config)
),
"OutpostManager",
["outpost"],
ctx.ethereum.wallet.signer
)
}
}
1 change: 1 addition & 0 deletions packages/cluster-tool/src/tools/ethereum/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./EthereumCollateralTool.js"
export * from "./EthereumSwapTool.js"
export * from "./EthereumYieldEmitterTool.js"
export * from "./EthereumNodeOwnerNftTool.js"
export * from "./EthereumOutpostManagerTool.js"
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import Fs from "node:fs"
import Path from "node:path"
import { ClusterBuildDefaults } from "@wireio/cluster-tool/orchestration"
import {
fixtureResolveEnvironment,
type ResolveEnvironment
} from "../config/resolveEnvironmentFixture.js"

import { collectPhaseNames, collectStepNames } from "./clusterBuildFixture.js"

/**
* WNE-41 — the genesis-delivery grant phase.
*
* `OPPInbound.epochIn` authorizes on the outpost's AccessManager until the
* first batch-operator roster installs, and the address that actually sends it
* is each batch operator's own EOA. These assertions pin the three properties
* that make the phase work: it exists in local mode, it carries ONE step per
* batch operator, and it is registered AFTER the operators are provisioned but
* BEFORE the daemons that deliver start.
*/
describe("ClusterBuildDefaults — bootstrap-delivery grants", () => {
let environment: ResolveEnvironment, externalConfigFile: string

beforeEach(() => {
environment = fixtureResolveEnvironment("bootstrap-delivery-")
externalConfigFile = Path.join(environment.rootPath, "external-outpost.json")
Fs.writeFileSync(
externalConfigFile,
JSON.stringify({
ethereum: {
addressFile: "outpost-addrs.json",
abiFiles: ["eth-abis/OPP.json"],
chainId: 11_155_111
},
solana: { idlFile: "solana-idls/liqsol_core.json" }
})
)
})

afterEach(() => {
environment.cleanup()
})

function baseOptions() {
return {
clusterPath: Path.join(environment.rootPath, "cluster"),
buildPath: environment.buildPath,
ethereumPath: "/fake/eth",
solanaPath: "/fake/sol"
}
}

it("composes one grant step per batch operator in local mode", async () => {
const batchOperatorCount = 3
const cluster = await ClusterBuildDefaults.create({
...baseOptions(),
batchOperatorCount
})

expect(collectPhaseNames(cluster.children)).toContain(
"GrantBootstrapDelivery"
)
const grantSteps = collectStepNames(cluster.children).filter(name =>
name.startsWith("grant-bootstrap-delivery-")
)
expect(grantSteps).toHaveLength(batchOperatorCount)
// One step per operator — never one step looping over N.
expect(new Set(grantSteps).size).toBe(batchOperatorCount)
})

it("grants AFTER operator provisioning and BEFORE the operator nodes start", async () => {
const cluster = await ClusterBuildDefaults.create(baseOptions())
const names = collectPhaseNames(cluster.children)

// The operators must exist to be granted, and must hold the role before
// their daemons deliver epoch 1.
expect(names.indexOf("GrantBootstrapDelivery")).toBeGreaterThan(
names.indexOf("Create batchops & uws")
)
expect(names.indexOf("GrantBootstrapDelivery")).toBeLessThan(
names.indexOf("OperatorNodes")
)
expect(names.indexOf("GrantBootstrapDelivery")).toBeLessThan(
names.indexOf("EpochBootstrap")
)
})

it("omits the phase in external-outpost mode", async () => {
const cluster = await ClusterBuildDefaults.create({
...baseOptions(),
externalOutpostConfig: externalConfigFile,
// External mode has no local outpost to bond underwriter collateral on,
// so `ClusterConfigProvider.resolve` demands an EXPLICIT zero.
underwriterCount: 0
})
const names = collectPhaseNames(cluster.children)

expect(names).toContain("MaterializeExternalOutposts")
// This run deployed no outpost and holds no admin authority over the
// external ones; their deliverers are authorized out of band.
expect(names).not.toContain("GrantBootstrapDelivery")
})
})
Loading
Loading