From 476b7a77f80cc64b43c60af7ef023d722cfc655c Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Fri, 21 Aug 2026 15:07:06 -0500 Subject: [PATCH] WNE-41: reconnect bootstrap-window authorization on OPPInbound.epochIn Change-Id: Ie1b3a354da8d05f78c8e4b670076d6e6cd076a54 --- .../src/orchestration/ClusterBuildDefaults.ts | 36 ++++ .../ethereum/EthereumOutpostManagerTool.ts | 162 +++++++++++++++++ .../cluster-tool/src/tools/ethereum/index.ts | 1 + ...sterBuildDefaultsBootstrapDelivery.test.ts | 103 +++++++++++ .../EthereumOutpostManagerTool.test.ts | 171 ++++++++++++++++++ 5 files changed, 473 insertions(+) create mode 100644 packages/cluster-tool/src/tools/ethereum/EthereumOutpostManagerTool.ts create mode 100644 packages/cluster-tool/tests/orchestration/ClusterBuildDefaultsBootstrapDelivery.test.ts create mode 100644 packages/cluster-tool/tests/tools/ethereum/EthereumOutpostManagerTool.test.ts diff --git a/packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts b/packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts index 40edc613..aed20dce 100644 --- a/packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts +++ b/packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts @@ -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" @@ -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( + postContractDeployment, + "GrantBootstrapDelivery", + "Authorize each batch operator to deliver the ETH outpost's genesis envelope" + ) + batchOperators.forEach(label => + bootstrapDeliveryPhase.push( + EthereumOutpostManagerTool.planGrantBootstrapDelivery( + 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. diff --git a/packages/cluster-tool/src/tools/ethereum/EthereumOutpostManagerTool.ts b/packages/cluster-tool/src/tools/ethereum/EthereumOutpostManagerTool.ts new file mode 100644 index 00000000..b4b5d658 --- /dev/null +++ b/packages/cluster-tool/src/tools/ethereum/EthereumOutpostManagerTool.ts @@ -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 + /** `OutpostManagerCommon.OPP_INBOUND_ROLE` — the `epochIn` role id. */ + OPP_INBOUND_ROLE: () => Promise + getAddress: () => Promise +} + +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, )`. + * + * 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 { + return ClusterBuildStep.create( + 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 { + 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( + ctx: C + ): OutpostManagerContract { + return loadOutpostContract( + ctx.config.ethereumPath, + EthereumCollateralTool.loadOutpostAddresses( + ClusterConfigProvider.ethereumDeploymentsPath(ctx.config) + ), + "OutpostManager", + ["outpost"], + ctx.ethereum.wallet.signer + ) + } +} diff --git a/packages/cluster-tool/src/tools/ethereum/index.ts b/packages/cluster-tool/src/tools/ethereum/index.ts index fdfd6284..e28ca827 100644 --- a/packages/cluster-tool/src/tools/ethereum/index.ts +++ b/packages/cluster-tool/src/tools/ethereum/index.ts @@ -3,3 +3,4 @@ export * from "./EthereumCollateralTool.js" export * from "./EthereumSwapTool.js" export * from "./EthereumYieldEmitterTool.js" export * from "./EthereumNodeOwnerNftTool.js" +export * from "./EthereumOutpostManagerTool.js" diff --git a/packages/cluster-tool/tests/orchestration/ClusterBuildDefaultsBootstrapDelivery.test.ts b/packages/cluster-tool/tests/orchestration/ClusterBuildDefaultsBootstrapDelivery.test.ts new file mode 100644 index 00000000..fcf0a3c1 --- /dev/null +++ b/packages/cluster-tool/tests/orchestration/ClusterBuildDefaultsBootstrapDelivery.test.ts @@ -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") + }) +}) diff --git a/packages/cluster-tool/tests/tools/ethereum/EthereumOutpostManagerTool.test.ts b/packages/cluster-tool/tests/tools/ethereum/EthereumOutpostManagerTool.test.ts new file mode 100644 index 00000000..c5e36aa4 --- /dev/null +++ b/packages/cluster-tool/tests/tools/ethereum/EthereumOutpostManagerTool.test.ts @@ -0,0 +1,171 @@ +import Fs from "node:fs" +import Os from "node:os" +import Path from "node:path" +import { KeyType } from "@wireio/sdk-core" +import { OperatorType } from "@wireio/opp-typescript-models" +import { EthereumOutpostManagerTool } from "@wireio/cluster-tool/tools/ethereum" +import type { ClusterBuildContext } from "@wireio/cluster-tool/orchestration" +import type { OperatorAccount } from "@wireio/cluster-tool/orchestration/outputs" +import type { EthereumKeyPair } from "@wireio/cluster-tool/types" +import { Report } from "@wireio/cluster-tool/report" +import { fixtureContext } from "../../config/clusterBuildContextFixture.js" + +/** A syntactically-valid deployed address for the fixture address map. */ +const OUTPOST_MANAGER_ADDRESS = "0x00000000000000000000000000000000000000c1" +/** The batch operator's own EOA — the address that actually sends `epochIn`. */ +const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000d1" +const OPERATOR_LABEL = "batchop.a" + +/** The two `OutpostManager` members the tool binds. */ +const OutpostManagerAbi = [ + "function grantRole(uint64 role, address grantee)", + "function OPP_INBOUND_ROLE() view returns (uint64)" +] + +/** + * A provisioned batch operator. `address` omitted models the operator that + * never received an EM key — the case the runner must refuse. + */ +function fixtureOperator(address?: string): OperatorAccount { + let ethereum: EthereumKeyPair + if (address != null) { + ethereum = { type: KeyType.EM, publicKey: "PUB_EM_fixture", address } + } + return { + label: OPERATOR_LABEL, + publicationLabel: OPERATOR_LABEL, + type: OperatorType.BATCH, + wire: { type: KeyType.K1, publicKey: "PUB_K1_fixture" }, + ethereum + } +} + +describe("EthereumOutpostManagerTool", () => { + let dataPath: string, ethereumPath: string + + beforeEach(() => { + dataPath = Fs.mkdtempSync(Path.join(Os.tmpdir(), "outpost-manager-data-")) + ethereumPath = Fs.mkdtempSync(Path.join(Os.tmpdir(), "outpost-manager-eth-")) + + const deploymentsPath = Path.join(dataPath, "ethereum-deployments") + Fs.mkdirSync(deploymentsPath, { recursive: true }) + Fs.writeFileSync( + Path.join(deploymentsPath, "outpost-addrs.json"), + JSON.stringify({ OutpostManager: OUTPOST_MANAGER_ADDRESS }) + ) + + const artifactDir = Path.join( + ethereumPath, + "artifacts", + "contracts", + "outpost", + "OutpostManager.sol" + ) + Fs.mkdirSync(artifactDir, { recursive: true }) + Fs.writeFileSync( + Path.join(artifactDir, "OutpostManager.json"), + JSON.stringify({ abi: OutpostManagerAbi }) + ) + }) + + afterEach(() => { + Fs.rmSync(dataPath, { recursive: true, force: true }) + Fs.rmSync(ethereumPath, { recursive: true, force: true }) + }) + + function contextWithOperator(address?: string): ClusterBuildContext { + const ctx = fixtureContext({ dataPath, ethereumPath }) + ctx.keyStore.setOperator(fixtureOperator(address)) + return ctx + } + + describe("planGrantBootstrapDelivery", () => { + it("captures the typed input and binds the named runner", () => { + const step = EthereumOutpostManagerTool.planGrantBootstrapDelivery( + Report.Actor.EthereumOutpost, + "grant-bootstrap-delivery-batchop.a", + "grant opp_inbound to batchop.a", + {}, + OPERATOR_LABEL + ) + expect(step.input).toEqual({ + kind: "EthereumOutpostManagerTool.GrantBootstrapDeliveryInput", + operatorLabel: OPERATOR_LABEL + }) + expect(step.runner).toBe( + EthereumOutpostManagerTool.runGrantBootstrapDelivery + ) + }) + }) + + describe("loadOutpostManager", () => { + it("binds the artifact ABI to the deployed address, as the deploy owner", () => { + const ctx = contextWithOperator(OPERATOR_ADDRESS) + const manager = EthereumOutpostManagerTool.loadOutpostManager(ctx) + + expect(manager.target).toBe(OUTPOST_MANAGER_ADDRESS) + // `grantRole` is `restricted`; only the deploy owner (the client's own + // signer, anvil HD index 0) holds ADMIN_ROLE on the outpost authority. + // Identity, not just address: a same-address signer on another provider + // would submit to the wrong chain. + expect(manager.runner).toBe(ctx.ethereum.wallet.signer) + }) + + it("throws LOUDLY when OutpostManager is absent from the address map", () => { + Fs.writeFileSync( + Path.join(dataPath, "ethereum-deployments", "outpost-addrs.json"), + JSON.stringify({}) + ) + expect(() => + EthereumOutpostManagerTool.loadOutpostManager( + contextWithOperator(OPERATOR_ADDRESS) + ) + ).toThrow(/OutpostManager not in outpost-addrs\.json/) + }) + }) + + describe("runGrantBootstrapDelivery", () => { + it("rejects an operator with no Ethereum address before touching the chain", async () => { + // A batch operator with no EM key has no EOA to authorize — granting the + // zero address would leave `epochIn` callable by nobody, silently. + await expect( + EthereumOutpostManagerTool.runGrantBootstrapDelivery( + contextWithOperator(), + { + kind: "EthereumOutpostManagerTool.GrantBootstrapDeliveryInput", + operatorLabel: OPERATOR_LABEL + }, + new AbortController().signal + ) + ).rejects.toThrow(/has no Ethereum address/) + }) + + it("rejects an unknown operator label", async () => { + await expect( + EthereumOutpostManagerTool.runGrantBootstrapDelivery( + contextWithOperator(OPERATOR_ADDRESS), + { + kind: "EthereumOutpostManagerTool.GrantBootstrapDeliveryInput", + operatorLabel: "batchop.missing" + }, + new AbortController().signal + ) + ).rejects.toThrow() + }) + + it("honours an already-aborted signal", async () => { + const controller = new AbortController() + controller.abort() + await expect( + EthereumOutpostManagerTool.runGrantBootstrapDelivery( + contextWithOperator(OPERATOR_ADDRESS), + { + kind: "EthereumOutpostManagerTool.GrantBootstrapDeliveryInput", + operatorLabel: OPERATOR_LABEL + }, + controller.signal + ) + ).rejects.toThrow() + }) + }) +})