diff --git a/docs/setup-external-cluster-guide.md b/docs/setup-external-cluster-guide.md index a8fb2ba65..6249b3b0e 100644 --- a/docs/setup-external-cluster-guide.md +++ b/docs/setup-external-cluster-guide.md @@ -170,9 +170,11 @@ wire-cluster-tool run --cluster-path /path/to/external ``` `run` resumes from the persisted state, dialing the addresses in -`ClusterConfig.bind`. The operator daemons' outpost-client endpoints -(`--outpost-ethereum-client` / `--outpost-solana-client`) are built at run time -from `config.bind.{anvil,solana}` — so they always match the merged bind. +`ClusterConfig.bind`. Artifact preparation regenerates +`data/ethereum-client.json` from `config.bind.anvil` and passes it through +`--outpost-ethereum-client-config-file`; the Solana endpoint remains the inline +`--outpost-solana-client` option built from `config.bind.solana`. Both therefore +match the merged bind on every relaunch. ## Step 5 (optional) — package per-node archives diff --git a/package.json b/package.json index feee00623..9ccd8d7f0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "@aws-sdk/client-sns": "3.1102.0", "@aws-sdk/client-ssm": "3.1102.0", "@aws-sdk/client-sts": "3.1102.0", + "@aws-sdk/credential-provider-env": "3.972.68", + "@aws-sdk/credential-provider-node": "3.972.79", "@types/proper-lockfile": "4.1.4", "uuid": "11", "get-port": "7.2.0", diff --git a/packages/cluster-tool/README.md b/packages/cluster-tool/README.md index 254009769..62374c9e9 100644 --- a/packages/cluster-tool/README.md +++ b/packages/cluster-tool/README.md @@ -323,6 +323,7 @@ After `create`: ├── anvil/ # anvil state (local ETH outpost only) ├── solana-ledger/ # validator ledger (local SOL outpost only) ├── eth-abis/ # address-embedded outpost ABIs + ├── ethereum-client.json # shared Ethereum client config for operator daemons ├── solana-idls/ # liqsol_core (opp-outpost) IDL ├── ethereum-deployments/ # outpost-addrs.json └── opp-debugging/ # OPP envelope .data / .metadata pairs @@ -332,6 +333,40 @@ In external-outpost mode no local `anvil` / `solana-ledger` state is written (`cluster-state.json` records them as `null`); the operator-daemon artifacts come from the `--external-outpost-config` instead. +### Generated Ethereum client configuration + +Artifact preparation writes one `data/ethereum-client.json`, and every operator +daemon passes it through `--outpost-ethereum-client-config-file`. The +protobuf-JSON document uses `schema_version: 1`, nests the stable `eth-default` +client and signature-provider ids under `connection`, and records `chain_id` as +a number. Signature-provider ids are process-local, so each daemon can register +its own Ethereum private key as `eth-default` while safely sharing the same +client configuration file. Daemon argument builders remain pure and reuse the +artifact on create, run, restart, and flow-provisioned starts. + +Local Anvil clusters embed the following finite `transaction_policy` in every +generated Ethereum client. The values live in +`AnvilEthereumTransactionPolicyConfig`; they are not production recommendations. + +| Limit | Anvil value | +|---|---:| +| Maximum priority fee per gas | `2,000,000,000` wei (2 gwei) | +| Maximum fee per gas | `100,000,000,000` wei (100 gwei) | +| Maximum final gas limit | `6,000,000` | +| Maximum total native cost | `700,000,000,000,000,000` wei (0.7 ETH) | + +Nodeop applies the final 20% estimate buffer before checking the gas cap. At +the full `6,000,000 × 100 gwei = 0.6 ETH` gas bound, the total-cost cap leaves +`0.1 ETH` for transaction value. These limits cover the measured local +`epochIn` and `commit` workloads, including the 4,392,032-gas remote emissions +high-water estimate after nodeop's 20% buffer, while retaining a finite +configuration-error boundary. External-outpost configuration continues to omit +the policy, because the reviewed limits for its operator-selected endpoint are +outside this tool's scope. Bios and producer-only nodes receive neither an +Ethereum signing client nor an orphaned client-config option. + +Production policy selection happens outside `wire-tools-ts`. + --- ## Programmatic usage diff --git a/packages/cluster-tool/src/config/AnvilEthereumTransactionPolicyConfig.ts b/packages/cluster-tool/src/config/AnvilEthereumTransactionPolicyConfig.ts new file mode 100644 index 000000000..1f2823f69 --- /dev/null +++ b/packages/cluster-tool/src/config/AnvilEthereumTransactionPolicyConfig.ts @@ -0,0 +1,40 @@ +import Assert from "node:assert" +import { + EthereumClientConfigurationConfig, + type EthereumTransactionPolicy +} from "./EthereumClientConfigurationConfig.js" + +/** Finite SEC-131 transaction limits for Anvil-backed operator daemons. */ +export namespace AnvilEthereumTransactionPolicyConfig { + /** Maximum EIP-1559 priority fee per gas in wei: 2 gwei. */ + export const MaximumPriorityFeePerGasWei = "2000000000" + /** Maximum EIP-1559 fee per gas in wei: 100 gwei. */ + export const MaximumFeePerGasWei = "100000000000" + /** Maximum final gas limit after nodeop's 20% estimate buffer. */ + export const MaximumGasLimit = "6000000" + /** Maximum native cost in wei: 0.7 ETH. */ + export const MaximumTotalNativeCostWei = "700000000000000000" + + /** + * Create the finite policy embedded in each local Anvil client. + * These limits are for local development and test clusters only; they are not + * production policy recommendations. + * + * @returns A validated policy using SEC-131's protobuf field spelling. + */ + export function create(): EthereumTransactionPolicy { + const policy: EthereumTransactionPolicy = { + max_priority_fee_per_gas_wei: MaximumPriorityFeePerGasWei, + max_fee_per_gas_wei: MaximumFeePerGasWei, + max_gas_limit: MaximumGasLimit, + max_total_native_cost_wei: MaximumTotalNativeCostWei + } + EthereumClientConfigurationConfig.assertTransactionPolicy(policy) + Assert.ok( + BigInt(policy.max_total_native_cost_wei) >= + BigInt(policy.max_gas_limit) * BigInt(policy.max_fee_per_gas_wei), + "Anvil Ethereum transaction policy total-cost cap must cover gas-limit × maximum-fee caps" + ) + return policy + } +} diff --git a/packages/cluster-tool/src/config/ClusterConfigProvider.ts b/packages/cluster-tool/src/config/ClusterConfigProvider.ts index df054dd78..e99afb381 100644 --- a/packages/cluster-tool/src/config/ClusterConfigProvider.ts +++ b/packages/cluster-tool/src/config/ClusterConfigProvider.ts @@ -604,14 +604,15 @@ export namespace ClusterConfigProvider { Path.isAbsolute(ref) ? ref : Path.resolve(baseDir, ref) return { ethereum: { + ...config.ethereum, addressFile: resolveRef(config.ethereum.addressFile), abiFiles: config.ethereum.abiFiles.map(resolveRef), - chainId: config.ethereum.chainId, ...(config.ethereum.liqEthAddressFile != null ? { liqEthAddressFile: resolveRef(config.ethereum.liqEthAddressFile) } : {}) }, solana: { + ...config.solana, idlFile: resolveRef(config.solana.idlFile), ...(config.solana.mintsFile != null ? { mintsFile: resolveRef(config.solana.mintsFile) } diff --git a/packages/cluster-tool/src/config/EthereumClientConfigurationConfig.ts b/packages/cluster-tool/src/config/EthereumClientConfigurationConfig.ts new file mode 100644 index 000000000..213804f4c --- /dev/null +++ b/packages/cluster-tool/src/config/EthereumClientConfigurationConfig.ts @@ -0,0 +1,188 @@ +import Assert from "node:assert" + +/** Finite limits nested in one host-side Ethereum signing client. */ +export interface EthereumTransactionPolicy { + readonly max_priority_fee_per_gas_wei: string + readonly max_fee_per_gas_wei: string + readonly max_gas_limit: string + readonly max_total_native_cost_wei: string +} + +/** One signing-capable Ethereum RPC connection. */ +export interface EthereumClientConnection { + readonly client_id: string + readonly signature_provider_id: string + readonly rpc_url: string +} + +/** One EVM signing client in nodeop's host-side configuration. */ +export interface EthereumClientConfiguration { + readonly connection: EthereumClientConnection + readonly chain_id: number + readonly transaction_policy?: EthereumTransactionPolicy +} + +/** Versioned ProtoJSON document consumed by `--outpost-ethereum-client-config-file`. */ +export interface EthereumClientConfigurationFile { + readonly schema_version: number + readonly clients: readonly EthereumClientConfiguration[] +} + +const CanonicalPositiveDecimal = /^[1-9][0-9]*$/, + SafeIdentifier = /^[A-Za-z0-9._-]{1,64}$/, + MaximumUint32 = 2 ** 32 - 1, + MaximumUint256 = (1n << 256n) - 1n + +/** Construct and validate the host-only Ethereum client ProtoJSON document. */ +export namespace EthereumClientConfigurationConfig { + /** Schema revision defined by `client_config.proto`. */ + export const SchemaVersion = 1 + + /** + * Create one nodeop client configuration. + * + * This is intentionally a host-side ProtoJSON factory, not an OPP protocol + * model. SEC-131 keeps the client configuration outside the shared OPP model + * bundles consumed by Solidity and Solana. + * + * @param clientId - Stable identifier referenced by the Ethereum plugins. + * @param signatureProviderId - Process-local Ethereum signing-provider id. + * @param rpcUrl - HTTP(S) Ethereum JSON-RPC endpoint. + * @param chainId - Positive EVM chain identifier expected from the endpoint. + * @param transactionPolicy - Optional finite local expenditure policy. + * @returns A validated document using the protobuf field spelling accepted by nodeop. + */ + export function create( + clientId: string, + signatureProviderId: string, + rpcUrl: string, + chainId: number, + transactionPolicy?: EthereumTransactionPolicy + ): EthereumClientConfigurationFile { + const configuration: EthereumClientConfigurationFile = { + schema_version: SchemaVersion, + clients: [ + { + connection: { + client_id: clientId, + signature_provider_id: signatureProviderId, + rpc_url: rpcUrl + }, + chain_id: chainId, + ...(transactionPolicy == null + ? {} + : { transaction_policy: transactionPolicy }) + } + ] + } + assertValid(configuration) + return configuration + } + + /** + * Validate and return the canonical ProtoJSON value written for nodeop. + * + * @param configuration - Host-side configuration to persist. + * @returns The same validated ProtoJSON document. + */ + export function toJson( + configuration: EthereumClientConfigurationFile + ): EthereumClientConfigurationFile { + assertValid(configuration) + return configuration + } + + /** + * Assert the local factory's document satisfies the SEC-131 schema boundary. + * + * @param configuration - Document to check before persisting it. + * @returns Nothing; invalid documents throw an assertion error. + */ + export function assertValid( + configuration: EthereumClientConfigurationFile + ): void { + Assert.equal( + configuration.schema_version, + SchemaVersion, + `Ethereum client configuration schema_version must be ${SchemaVersion}` + ) + Assert.equal( + configuration.clients.length, + 1, + "Operator daemon Ethereum configuration must contain exactly one client" + ) + + const [client] = configuration.clients + Assert.ok(client.connection != null, "Ethereum client connection must be present") + Assert.match( + client.connection.client_id, + SafeIdentifier, + "Ethereum client_id must be 1-64 ASCII letters, digits, '.', '_', or '-'" + ) + Assert.ok( + client.connection.signature_provider_id.length > 0, + "Ethereum signature_provider_id must not be empty" + ) + const rpcUrl = new URL(client.connection.rpc_url) + Assert.ok( + (rpcUrl.protocol === "http:" || rpcUrl.protocol === "https:") && + rpcUrl.hostname.length > 0 && + rpcUrl.hash.length === 0, + "Ethereum rpc_url must use http or https with a host and no fragment" + ) + Assert.ok( + Number.isInteger(client.chain_id) && + client.chain_id > 0 && + client.chain_id <= MaximumUint32, + "Ethereum chain_id must be a positive uint32" + ) + + if (client.transaction_policy != null) { + assertTransactionPolicy(client.transaction_policy) + } + } + + /** + * Assert that a finite policy uses canonical uint256 decimal fields and a + * valid EIP-1559 fee relationship. + * + * @param policy - Transaction policy nested in a client configuration. + * @returns Nothing; invalid policies throw an assertion error. + */ + export function assertTransactionPolicy( + policy: EthereumTransactionPolicy + ): void { + const maximumPriorityFeePerGas = positiveUint( + policy.max_priority_fee_per_gas_wei, + "max_priority_fee_per_gas_wei", + MaximumUint256 + ), + maximumFeePerGas = positiveUint( + policy.max_fee_per_gas_wei, + "max_fee_per_gas_wei", + MaximumUint256 + ) + positiveUint(policy.max_gas_limit, "max_gas_limit", MaximumUint256) + positiveUint( + policy.max_total_native_cost_wei, + "max_total_native_cost_wei", + MaximumUint256 + ) + Assert.ok( + maximumPriorityFeePerGas <= maximumFeePerGas, + "Ethereum priority-fee cap must not exceed maximum-fee cap" + ) + } +} + +/** Parse one canonical positive unsigned decimal bounded by `maximum`. */ +function positiveUint(value: string, field: string, maximum: bigint): bigint { + Assert.match( + value, + CanonicalPositiveDecimal, + `Ethereum ${field} must be a canonical positive decimal string` + ) + const parsed = BigInt(value) + Assert.ok(parsed <= maximum, `Ethereum ${field} exceeds its supported domain`) + return parsed +} diff --git a/packages/cluster-tool/src/config/index.ts b/packages/cluster-tool/src/config/index.ts index 56d182d13..738410b19 100644 --- a/packages/cluster-tool/src/config/index.ts +++ b/packages/cluster-tool/src/config/index.ts @@ -1,10 +1,12 @@ export * from "./ApiNodeConfig.js" export * from "./BatchOperatorSchedule.js" +export * from "./AnvilEthereumTransactionPolicyConfig.js" export * from "./BindConfigProvider.js" export * from "./ClusterBuildOptions.js" export * from "./ClusterConfigProvider.js" export * from "./DaemonConfig.js" export * from "./ExternalClusterConfigProvider.js" +export * from "./EthereumClientConfigurationConfig.js" export * from "./NodeConfig.js" export * from "./SignatureProviderConfigProvider.js" export * from "./SSMClientProvider.js" diff --git a/packages/cluster-tool/src/orchestration/outputs/OperatorDaemonArtifacts.ts b/packages/cluster-tool/src/orchestration/outputs/OperatorDaemonArtifacts.ts index 99a2107fd..2978db511 100644 --- a/packages/cluster-tool/src/orchestration/outputs/OperatorDaemonArtifacts.ts +++ b/packages/cluster-tool/src/orchestration/outputs/OperatorDaemonArtifacts.ts @@ -11,6 +11,8 @@ export interface OperatorDaemonArtifacts { readonly ethereumAbiFiles: string[] /** Deployed Ethereum outpost addresses (from `outpost-addrs.json`). */ readonly ethereumAddresses: Record + /** Generated unified Ethereum client JSON shared by operator daemon processes. */ + readonly ethereumClientConfigurationFile: string /** The OPP outpost program id (base58) — `liqsol_core`'s `declare_id`. */ readonly solanaProgramId: string /** Cluster-local verbatim copy of the `liqsol_core` (OPP outpost) IDL. */ @@ -18,7 +20,8 @@ export interface OperatorDaemonArtifacts { } /** Typed cross-step handle to the prepared {@link OperatorDaemonArtifacts}. */ -export const OperatorDaemonArtifactsKey: OutputKey = outputKey( - "cluster.operatorDaemonArtifacts", - "outpost deploy artifacts for operator daemon command lines (ETH ABIs + addrs, SOL program id + IDL)" -) +export const OperatorDaemonArtifactsKey: OutputKey = + outputKey( + "cluster.operatorDaemonArtifacts", + "outpost artifacts for operator daemon command lines (ETH ABIs + client configs, SOL program id + IDL)" + ) diff --git a/packages/cluster-tool/src/orchestration/steps/ExternalOutpostSteps.ts b/packages/cluster-tool/src/orchestration/steps/ExternalOutpostSteps.ts index 97238ee10..9cf9ac1bb 100644 --- a/packages/cluster-tool/src/orchestration/steps/ExternalOutpostSteps.ts +++ b/packages/cluster-tool/src/orchestration/steps/ExternalOutpostSteps.ts @@ -9,6 +9,7 @@ import { Report } from "../../report/Report.js" import { getLogger } from "../../logging/Logger.js" import { NodeConfig, NodeRole } from "../../config/NodeConfig.js" import { ClusterConfigProvider } from "../../config/ClusterConfigProvider.js" +import { EthereumClientConfigurationConfig } from "../../config/EthereumClientConfigurationConfig.js" import { NodeopProcess } from "../../cluster/processes/NodeopProcess.js" import { OperatorDaemonTool } from "../../tools/wire/OperatorDaemonTool.js" import { ClusterBuildContext } from "../ClusterBuildContext.js" @@ -114,7 +115,9 @@ export namespace ExternalOutpostSteps { "ExternalOutpostSteps.planMaterialize requires config.externalOutposts (external-outpost mode only)" ) const dataPath = ctx.config.dataPath, - deploymentsDir = ClusterConfigProvider.ethereumDeploymentsPath(ctx.config), + deploymentsDir = ClusterConfigProvider.ethereumDeploymentsPath( + ctx.config + ), abiDir = Path.join(dataPath, OperatorDaemonTool.EthereumAbiSubpath), idlDir = Path.join(dataPath, OperatorDaemonTool.SolanaIdlSubpath), materialize = (source: string, destination: string): void => { @@ -154,9 +157,10 @@ export namespace ExternalOutpostSteps { * Populate {@link OperatorDaemonArtifactsKey} from the MATERIALIZED dataPath * files — the external replacement for `OperatorDaemonTool.planArtifactPreparation` * (whose ABI/IDL sources are the wire-ethereum/wire-solana CHECKOUTS, absent in - * external mode). Reads ONLY `dataPath`, NEVER `config.externalOutposts`: + * external mode). Reads deploy artifacts ONLY from `dataPath`: * `outpost-addrs.json`, `eth-abis/*.json`, `solana-idls/.json` (program id - * = its top-level `address`). Run AFTER {@link planMaterialize}. + * = its top-level `address`), then generates `ethereum-client.json` from the + * resolved external network coordinates. Run AFTER {@link planMaterialize}. * * @param actor - The Report actor. * @param name - Step name. @@ -251,9 +255,30 @@ export namespace ExternalOutpostSteps { ) ) + const ethereumClientConfigurationFile = Path.join( + dataPath, + OperatorDaemonTool.EthereumClientConfigurationFilename + ), + network = OperatorDaemonTool.networkFromConfig(ctx.config), + ethereumClientConfiguration = EthereumClientConfigurationConfig.create( + OperatorDaemonTool.EthereumClientId, + OperatorDaemonTool.EthereumSignatureProviderId, + network.ethereumRpcUrl, + network.ethereumChainId + ) + Fs.writeFileSync( + ethereumClientConfigurationFile, + JSON.stringify( + EthereumClientConfigurationConfig.toJson(ethereumClientConfiguration), + null, + 2 + ) + ) + ctx.outputs.set(OperatorDaemonArtifactsKey, { ethereumAbiFiles, ethereumAddresses, + ethereumClientConfigurationFile, solanaProgramId, solanaIdlFile: idlFile }) diff --git a/packages/cluster-tool/src/tools/wire/OperatorDaemonTool.ts b/packages/cluster-tool/src/tools/wire/OperatorDaemonTool.ts index c444e78c6..13506e352 100644 --- a/packages/cluster-tool/src/tools/wire/OperatorDaemonTool.ts +++ b/packages/cluster-tool/src/tools/wire/OperatorDaemonTool.ts @@ -8,7 +8,7 @@ * {@link planArtifactPreparation} is a Step (run once, after both outpost deploys) that * writes the cluster-local artifact files and stores the typed * {@link OperatorDaemonArtifacts}; {@link batchOperatorArgs} / - * {@link underwriterArgs} are PURE value builders the operator-node start runner + * {@link underwriterArgs} are pure value builders the operator-node start runner * composes into `NodeopProcess` extra args. */ @@ -25,7 +25,9 @@ import { match } from "ts-pattern" import { KeyGenerator } from "../../clients/wire/KeyGenerator.js" import { WireClient } from "../../clients/wire/WireClient.js" import { BindConfigProvider } from "../../config/BindConfigProvider.js" +import { AnvilEthereumTransactionPolicyConfig } from "../../config/AnvilEthereumTransactionPolicyConfig.js" import { ClusterConfigProvider } from "../../config/ClusterConfigProvider.js" +import { EthereumClientConfigurationConfig } from "../../config/EthereumClientConfigurationConfig.js" import { NodeConfig, NodeRole } from "../../config/NodeConfig.js" import { AnvilProcess } from "../../cluster/processes/AnvilProcess.js" import { NodeopProcess } from "../../cluster/processes/NodeopProcess.js" @@ -99,6 +101,8 @@ export namespace OperatorDaemonTool { export const UnderwriterActionTimeoutMs = 30_000 /** The single ethereum outpost client id every plugin arg references. */ export const EthereumClientId = "eth-default" + /** Process-local signature-provider id referenced by the shared Ethereum client file. */ + export const EthereumSignatureProviderId = "eth-default" /** The single solana outpost client id every plugin arg references. */ export const SolanaClientId = "sol-default" /** The `sysio.chains` codename keying the ETH outpost wiring specs. */ @@ -133,6 +137,8 @@ export namespace OperatorDaemonTool { ] as const /** Cluster-data subpath holding the generated `{contractName, address, abi}` files. */ export const EthereumAbiSubpath = "eth-abis" + /** Cluster-data filename for the shared unified Ethereum client configuration. */ + export const EthereumClientConfigurationFilename = "ethereum-client.json" /** Cluster-data subpath holding the copied OPP outpost IDL. */ export const SolanaIdlSubpath = "solana-idls" /** The OPP outpost IDL filename (cluster-local verbatim copy). */ @@ -196,8 +202,9 @@ export namespace OperatorDaemonTool { * Prepare the artifacts every operator daemon's command line references: * generate `/eth-abis/.json` (`{contractName, address, abi}`, * from the wire-ethereum hardhat artifacts + `outpost-addrs.json`), copy the - * `liqsol_core` (OPP outpost) IDL to `/solana-idls/`, resolve the SOL program id, - * and store the typed {@link OperatorDaemonArtifacts}. Runs ONCE, after both + * `liqsol_core` (OPP outpost) IDL to `/solana-idls/`, generate the + * unified Ethereum client file, resolve the SOL program id, and + * store the typed {@link OperatorDaemonArtifacts}. Runs ONCE, after both * outpost deploys, before any operator node starts. */ export function planArtifactPreparation< @@ -299,9 +306,37 @@ export namespace OperatorDaemonTool { ) Fs.copyFileSync(idlSource, solanaIdlFile) + // Signature-provider ids are process-local, so every daemon can register + // its own key under the stable name referenced by this shared client file. + const ethereumClientConfigurationFile = Path.join( + dataPath, + EthereumClientConfigurationFilename + ), + network = networkFromConfig(ctx.config), + transactionPolicy = + ctx.config.externalOutposts == null + ? AnvilEthereumTransactionPolicyConfig.create() + : undefined, + ethereumClientConfiguration = EthereumClientConfigurationConfig.create( + EthereumClientId, + EthereumSignatureProviderId, + network.ethereumRpcUrl, + network.ethereumChainId, + transactionPolicy + ) + Fs.writeFileSync( + ethereumClientConfigurationFile, + JSON.stringify( + EthereumClientConfigurationConfig.toJson(ethereumClientConfiguration), + null, + 2 + ) + ) + ctx.outputs.set(OperatorDaemonArtifactsKey, { ethereumAbiFiles, ethereumAddresses, + ethereumClientConfigurationFile, solanaProgramId, solanaIdlFile }) @@ -310,9 +345,10 @@ export namespace OperatorDaemonTool { StepExtraRecorder.record({ client: "harness", kind: "artifact", - text: "address-embedded ETH ABI files + liqsol_core (OPP outpost) IDL prepared for the operator daemons", + text: "address-embedded ETH ABIs + unified Ethereum client config + liqsol_core IDL prepared for the operator daemons", ethereumAbiFiles, ethereumAddresses, + ethereumClientConfigurationFile, solanaProgramId, solanaIdlFile }) @@ -337,14 +373,14 @@ export namespace OperatorDaemonTool { ) } - /** The outpost signature-provider + client specs shared by both daemon types. */ + /** The outpost signature-provider + client configuration shared by both daemon types. */ function outpostClientArgs( operator: OperatorAccount, artifacts: OperatorDaemonArtifacts, network: OperatorDaemonNetwork, keySourceFor: ClusterConfigProvider.SignatureProviderSourceFor ): string[] { - const ethereumProvider = `eth-${operator.account}`, + const ethereumProvider = EthereumSignatureProviderId, solanaProvider = `sol-${operator.account}` return [ ...pair( @@ -356,13 +392,8 @@ export namespace OperatorDaemonTool { ) ), ...pair( - "--outpost-ethereum-client", - [ - EthereumClientId, - ethereumProvider, - network.ethereumRpcUrl, - String(network.ethereumChainId) - ].join(",") + "--outpost-ethereum-client-config-file", + artifacts.ethereumClientConfigurationFile ), ...artifacts.ethereumAbiFiles.flatMap(file => pair("--ethereum-abi-file", file) @@ -401,7 +432,12 @@ export namespace OperatorDaemonTool { assertOutpostKeys(operator) return [ ...pair("--read-mode", WireClient.FinalityType.irreversible), - ...pluginArgs(debuggingGatedPlugins(BatchOperatorPlugins, network.debuggingServerEnabled)), + ...pluginArgs( + debuggingGatedPlugins( + BatchOperatorPlugins, + network.debuggingServerEnabled + ) + ), ...pair( "--signature-provider", KeyGenerator.toSignatureProvider( @@ -424,7 +460,7 @@ export namespace OperatorDaemonTool { // Per-chain outpost bindings (repeatable CSV specs; replaced the removed // --batch-eth-{client-id,opp-addr,opp-inbound-addr} / --batch-sol-program-id — // the EVM RPC client is auto-selected by matching the chains row's - // external_chain_id against the --outpost-ethereum-client chain ids): + // external_chain_id against the configured Ethereum client chain ids): // EVM: ,, // SVM: , ...pair( @@ -468,7 +504,12 @@ export namespace OperatorDaemonTool { assertOutpostKeys(operator) return [ ...pair("--read-mode", WireClient.FinalityType.irreversible), - ...pluginArgs(debuggingGatedPlugins(UnderwriterPlugins, network.debuggingServerEnabled)), + ...pluginArgs( + debuggingGatedPlugins( + UnderwriterPlugins, + network.debuggingServerEnabled + ) + ), ...pair( "--signature-provider", KeyGenerator.toSignatureProvider( diff --git a/packages/cluster-tool/tests/config/AnvilEthereumTransactionPolicyConfig.test.ts b/packages/cluster-tool/tests/config/AnvilEthereumTransactionPolicyConfig.test.ts new file mode 100644 index 000000000..cfc3c2d13 --- /dev/null +++ b/packages/cluster-tool/tests/config/AnvilEthereumTransactionPolicyConfig.test.ts @@ -0,0 +1,35 @@ +import { AnvilEthereumTransactionPolicyConfig } from "@wireio/cluster-tool/config" + +const RemoteEpochInGasEstimate = 4_392_032n, + NodeopGasEstimateBufferNumerator = 6n, + NodeopGasEstimateBufferDenominator = 5n + +describe("AnvilEthereumTransactionPolicyConfig", () => { + it("defines the finite local SEC-131 limits in ProtoJSON fields", () => { + expect(AnvilEthereumTransactionPolicyConfig.create()).toEqual({ + max_priority_fee_per_gas_wei: "2000000000", + max_fee_per_gas_wei: "100000000000", + max_gas_limit: "6000000", + max_total_native_cost_wei: "700000000000000000" + }) + }) + + it("keeps the full fee, gas, and total-cost cap relationship valid", () => { + const policy = AnvilEthereumTransactionPolicyConfig.create() + expect(BigInt(policy.max_priority_fee_per_gas_wei)).toBeLessThanOrEqual( + BigInt(policy.max_fee_per_gas_wei) + ) + expect(BigInt(policy.max_total_native_cost_wei)).toBeGreaterThanOrEqual( + BigInt(policy.max_gas_limit) * BigInt(policy.max_fee_per_gas_wei) + ) + }) + + it("covers the buffered remote epochIn gas high-water mark", () => { + const bufferedGasLimit = + (RemoteEpochInGasEstimate * NodeopGasEstimateBufferNumerator) / + NodeopGasEstimateBufferDenominator + expect( + BigInt(AnvilEthereumTransactionPolicyConfig.MaximumGasLimit) + ).toBeGreaterThanOrEqual(bufferedGasLimit) + }) +}) diff --git a/packages/cluster-tool/tests/config/ClusterConfigProvider.test.ts b/packages/cluster-tool/tests/config/ClusterConfigProvider.test.ts index fdbbf8a6d..06fbab917 100644 --- a/packages/cluster-tool/tests/config/ClusterConfigProvider.test.ts +++ b/packages/cluster-tool/tests/config/ClusterConfigProvider.test.ts @@ -653,6 +653,8 @@ describe("ClusterConfigProvider", () => { }) describe("external outposts vs underwriters", () => { + const ExternalEthereumRpcUrl = "https://ethereum-rpc.external.example/", + ExternalSolanaRpcUrl = "https://solana-rpc.external.example/" let environment: ResolveEnvironment, externalConfigFile: string beforeEach(() => { @@ -664,9 +666,13 @@ describe("ClusterConfigProvider", () => { ethereum: { addressFile: "outpost-addrs.json", abiFiles: ["eth-abis/OPP.json"], - chainId: 11_155_111 + chainId: 11_155_111, + rpcUrl: ExternalEthereumRpcUrl }, - solana: { idlFile: "solana-idls/liqsol_core.json" } + solana: { + idlFile: "solana-idls/liqsol_core.json", + rpcUrl: ExternalSolanaRpcUrl + } }) ) }) @@ -708,6 +714,12 @@ describe("ClusterConfigProvider", () => { externalOptions({ underwriterCount: 0 }) ) expect(config.underwriterCount).toBe(0) + expect(config.externalOutposts?.ethereum.rpcUrl).toBe( + ExternalEthereumRpcUrl + ) + expect(config.externalOutposts?.solana.rpcUrl).toBe( + ExternalSolanaRpcUrl + ) expect(config.externalOutposts).not.toBeNull() }) diff --git a/packages/cluster-tool/tests/config/EthereumClientConfigurationConfig.test.ts b/packages/cluster-tool/tests/config/EthereumClientConfigurationConfig.test.ts new file mode 100644 index 000000000..570839da3 --- /dev/null +++ b/packages/cluster-tool/tests/config/EthereumClientConfigurationConfig.test.ts @@ -0,0 +1,102 @@ +import { + AnvilEthereumTransactionPolicyConfig, + EthereumClientConfigurationConfig, + type EthereumClientConfigurationFile, + type EthereumTransactionPolicy +} from "@wireio/cluster-tool/config" + +const FinitePolicy: EthereumTransactionPolicy = + AnvilEthereumTransactionPolicyConfig.create() + +function fileWith( + changes: Partial +): EthereumClientConfigurationFile { + const file = EthereumClientConfigurationConfig.create( + "eth-default", + "eth-batchopaaaa", + "http://anvil.test", + 31_337 + ) + return { ...file, clients: [{ ...file.clients[0], ...changes }] } +} + +describe("EthereumClientConfigurationConfig", () => { + it("emits the final SEC-131 host-only ProtoJSON shape", () => { + expect( + EthereumClientConfigurationConfig.toJson( + EthereumClientConfigurationConfig.create( + "eth-default", + "eth-batchopaaaa", + "http://anvil.test", + 31_337, + FinitePolicy + ) + ) + ).toEqual({ + schema_version: 1, + clients: [ + { + connection: { + client_id: "eth-default", + signature_provider_id: "eth-batchopaaaa", + rpc_url: "http://anvil.test" + }, + chain_id: 31_337, + transaction_policy: FinitePolicy + } + ] + }) + }) + + it("keeps external clients policy-free for operator-selected production limits", () => { + expect( + EthereumClientConfigurationConfig.create( + "eth-default", + "eth-default", + "https://ethereum.example", + 11_155_111 + ) + ).toEqual({ + schema_version: 1, + clients: [ + { + connection: { + client_id: "eth-default", + signature_provider_id: "eth-default", + rpc_url: "https://ethereum.example" + }, + chain_id: 11_155_111 + } + ] + }) + }) + + it("rejects invalid host configuration and policy values", () => { + expect(() => + EthereumClientConfigurationConfig.create( + "bad,id", + "eth-default", + "http://anvil.test", + 31_337 + ) + ).toThrow(/client_id must be 1-64 ASCII/) + expect(() => + EthereumClientConfigurationConfig.create( + "eth-default", + "eth-default", + "ws://anvil.test", + 31_337 + ) + ).toThrow(/rpc_url must use http or https/) + expect(() => + EthereumClientConfigurationConfig.assertValid( + fileWith({ + transaction_policy: { + ...FinitePolicy, + max_gas_limit: "06000000" + } + }) + ) + ).toThrow(/max_gas_limit must be a canonical positive decimal string/) + }) +}) diff --git a/packages/cluster-tool/tests/orchestration/steps/ExternalClusterConfigSteps.test.ts b/packages/cluster-tool/tests/orchestration/steps/ExternalClusterConfigSteps.test.ts index 234c923a6..8bb929089 100644 --- a/packages/cluster-tool/tests/orchestration/steps/ExternalClusterConfigSteps.test.ts +++ b/packages/cluster-tool/tests/orchestration/steps/ExternalClusterConfigSteps.test.ts @@ -24,6 +24,7 @@ import { NodeopProcess } from "@wireio/cluster-tool/cluster/processes" import { + AnvilEthereumTransactionPolicyConfig, ClusterConfigProvider, DaemonConfig, NodeConfig, @@ -40,7 +41,9 @@ import { SolanaOutpostProgramTool } from "@wireio/cluster-tool/tools/solana" import { OperatorDaemonTool } from "@wireio/cluster-tool/tools/wire" import { keyPairFromPrivate, + StartScriptVariable, toDialAddress, + toRelocatableToken, toURL } from "@wireio/cluster-tool/utils" import { fixtureContext } from "../../config/clusterBuildContextFixture.js" @@ -715,6 +718,13 @@ describe("Steps.externalClusterConfig (create-external-config pipeline)", () => scriptFile = DaemonConfig.startScriptFile(node.nodePath), argv = startScriptArgv(scriptFile), account = assertOperatorAccount(merged, node.batchOperatorLabel), + ethereumClientConfigurationFile = Path.join( + merged.dataPath, + OperatorDaemonTool.EthereumClientConfigurationFilename + ), + ethereumClientConfiguration = JSON.parse( + Fs.readFileSync(ethereumClientConfigurationFile, "utf-8") + ), // FULL `address:port` URLs on both sides. `shiftPorts` moves PORTS only, // so the local and external ADDRESSES are byte-identical — an // address-only assertion passes against a completely un-rebound script. @@ -744,14 +754,24 @@ describe("Steps.externalClusterConfig (create-external-config pipeline)", () => ) expect(argv.length).toBeGreaterThan(0) - expect(argvValuesOf(argv, "--outpost-ethereum-client")).toEqual([ - [ - OperatorDaemonTool.EthereumClientId, - `eth-${account}`, - externalEthereumRpcUrl, - String(AnvilProcess.DefaultChainId) - ].join(",") + expect(argvValuesOf(argv, "--outpost-ethereum-client")).toEqual([]) + expect(argvValuesOf(argv, "--outpost-ethereum-client-config-file")).toEqual([ + toRelocatableToken(ethereumClientConfigurationFile, [ + { + prefix: merged.clusterPath, + variable: StartScriptVariable.CLUSTER_DIR + } + ]) ]) + expect(ethereumClientConfiguration.clients[0].connection.rpc_url).toBe( + externalEthereumRpcUrl + ) + expect(ethereumClientConfiguration.clients[0].chain_id).toBe( + AnvilProcess.DefaultChainId + ) + expect(ethereumClientConfiguration.clients[0].transaction_policy).toEqual( + AnvilEthereumTransactionPolicyConfig.create() + ) expect(argvValuesOf(argv, "--outpost-solana-client")).toEqual([ [ OperatorDaemonTool.SolanaClientId, @@ -764,9 +784,10 @@ describe("Steps.externalClusterConfig (create-external-config pipeline)", () => ]) // Target the SPECIFIC option specs, never a blanket scan of the file: a // `--signature-provider` value legitimately contains `ethereum` / `solana`. - const [ethereumSpec] = argvValuesOf(argv, "--outpost-ethereum-client"), - [solanaSpec] = argvValuesOf(argv, "--outpost-solana-client") - expect(ethereumSpec).not.toContain(localEthereumRpcUrl) + const [solanaSpec] = argvValuesOf(argv, "--outpost-solana-client") + expect(ethereumClientConfiguration.clients[0].connection.rpc_url).not.toContain( + localEthereumRpcUrl + ) expect(solanaSpec).not.toContain(localSolanaRpcUrl) expect(argvValuesOf(argv, "--ext-debugging-server")).not.toContain( localDebuggingServerUrl @@ -957,7 +978,14 @@ describe("Steps.externalClusterConfig (create-external-config pipeline)", () => node = assertBatchOperatorNode(merged), scriptFile = DaemonConfig.startScriptFile(node.nodePath), argv = startScriptArgv(scriptFile), - account = assertOperatorAccount(merged, node.batchOperatorLabel) + account = assertOperatorAccount(merged, node.batchOperatorLabel), + ethereumClientConfigurationFile = Path.join( + merged.dataPath, + OperatorDaemonTool.EthereumClientConfigurationFilename + ), + ethereumClientConfiguration = JSON.parse( + Fs.readFileSync(ethereumClientConfigurationFile, "utf-8") + ) // The Rebind carries the non-file fields through UNTOUCHED while moving // every FILE ref in-tree — a re-stated field list dropped `rpcUrl` here and @@ -968,16 +996,24 @@ describe("Steps.externalClusterConfig (create-external-config pipeline)", () => expect(merged.externalOutposts.ethereum.addressFile.startsWith(externalDir)).toBe(true) expect(merged.externalOutposts.solana.idlFile.startsWith(externalDir)).toBe(true) - // The 4-field ETH client spec: id, provider, the AUTHORITATIVE endpoint, - // and the config's REAL chain id (never the anvil default). - expect(argvValuesOf(argv, "--outpost-ethereum-client")).toEqual([ - [ - OperatorDaemonTool.EthereumClientId, - `eth-${account}`, - ExternalEthereumRpcUrl, - String(ExternalChainId) - ].join(",") + // The generated ETH client file keeps the AUTHORITATIVE endpoint and real + // chain id; the argv references that stable artifact. + expect(argvValuesOf(argv, "--outpost-ethereum-client")).toEqual([]) + expect(argvValuesOf(argv, "--outpost-ethereum-client-config-file")).toEqual([ + toRelocatableToken(ethereumClientConfigurationFile, [ + { + prefix: merged.clusterPath, + variable: StartScriptVariable.CLUSTER_DIR + } + ]) ]) + expect(ethereumClientConfiguration.clients[0].connection.rpc_url).toBe( + ExternalEthereumRpcUrl + ) + expect(ethereumClientConfiguration.clients[0].chain_id).toBe( + ExternalChainId + ) + expect(ethereumClientConfiguration.clients[0].transaction_policy).toBeUndefined() expect(argvValuesOf(argv, "--outpost-solana-client")).toEqual([ [ OperatorDaemonTool.SolanaClientId, diff --git a/packages/cluster-tool/tests/orchestration/steps/ExternalOutpostSteps.test.ts b/packages/cluster-tool/tests/orchestration/steps/ExternalOutpostSteps.test.ts index 7942aeb31..5021f06df 100644 --- a/packages/cluster-tool/tests/orchestration/steps/ExternalOutpostSteps.test.ts +++ b/packages/cluster-tool/tests/orchestration/steps/ExternalOutpostSteps.test.ts @@ -68,8 +68,12 @@ describe("Steps.externalOutpost (materialize + publish)", () => { it("materializes the config-referenced files into the canonical data dir", async () => { const ctx = externalContext() await Steps.externalOutpost.runMaterialize(ctx, null, signal) - const deploymentsDir = ClusterConfigProvider.ethereumDeploymentsPath(ctx.config) - expect(Fs.existsSync(Path.join(deploymentsDir, "outpost-addrs.json"))).toBe(true) + const deploymentsDir = ClusterConfigProvider.ethereumDeploymentsPath( + ctx.config + ) + expect(Fs.existsSync(Path.join(deploymentsDir, "outpost-addrs.json"))).toBe( + true + ) expect( Fs.existsSync( Path.join(dataPath, OperatorDaemonTool.EthereumAbiSubpath, "OPP.json") @@ -90,13 +94,34 @@ describe("Steps.externalOutpost (materialize + publish)", () => { const ctx = externalContext() await Steps.externalOutpost.runMaterialize(ctx, null, signal) await Steps.externalOutpost.runPublishArtifacts(ctx, null, signal) - const artifacts = ctx.outputs.get(OperatorDaemonArtifactsKey) - expect(artifacts?.ethereumAddresses.OPP).toBe(OppAddress) - expect(artifacts?.ethereumAbiFiles.some(file => file.endsWith("OPP.json"))).toBe( - true + const artifacts = ctx.outputs.assert(OperatorDaemonArtifactsKey) + expect(artifacts.ethereumAddresses.OPP).toBe(OppAddress) + expect( + artifacts.ethereumAbiFiles.some(file => file.endsWith("OPP.json")) + ).toBe(true) + expect( + JSON.parse( + Fs.readFileSync(artifacts.ethereumClientConfigurationFile, "utf-8") + ) + ).toEqual({ + schema_version: 1, + clients: [ + { + connection: { + client_id: OperatorDaemonTool.EthereumClientId, + signature_provider_id: + OperatorDaemonTool.EthereumSignatureProviderId, + rpc_url: OperatorDaemonTool.networkFromConfig(ctx.config) + .ethereumRpcUrl + }, + chain_id: 11_155_111 + } + ] + }) + expect(artifacts.solanaProgramId).toBe(ProgramId) + expect(artifacts.solanaIdlFile).toContain( + OperatorDaemonTool.SolanaIdlFilename ) - expect(artifacts?.solanaProgramId).toBe(ProgramId) - expect(artifacts?.solanaIdlFile).toContain(OperatorDaemonTool.SolanaIdlFilename) }) it("materialize fails fast when a referenced source file is absent", async () => { @@ -130,7 +155,10 @@ describe("Steps.externalOutpost (materialize + publish)", () => { it("publish fails when a required SOL IDL instruction is missing", async () => { Fs.writeFileSync( idlFile, - JSON.stringify({ address: ProgramId, instructions: [{ name: "epoch_in" }] }) + JSON.stringify({ + address: ProgramId, + instructions: [{ name: "epoch_in" }] + }) ) const ctx = externalContext() await Steps.externalOutpost.runMaterialize(ctx, null, signal) diff --git a/packages/cluster-tool/tests/orchestration/steps/processes/NodeopProcessSteps.test.ts b/packages/cluster-tool/tests/orchestration/steps/processes/NodeopProcessSteps.test.ts index be473afdf..932b926f7 100644 --- a/packages/cluster-tool/tests/orchestration/steps/processes/NodeopProcessSteps.test.ts +++ b/packages/cluster-tool/tests/orchestration/steps/processes/NodeopProcessSteps.test.ts @@ -63,6 +63,7 @@ const artifactsFixture: OperatorDaemonArtifacts = { OperatorRegistry: "0x3333333333333333333333333333333333333333", ReserveManager: "0x4444444444444444444444444444444444444444" }, + ethereumClientConfigurationFile: "/cluster/data/ethereum-client.json", solanaProgramId: "GrqvbZLCLkfeSQqvE7rL8XKHVWjNhAG2faLsY8yr9tD5", solanaIdlFile: "/cluster/data/solana-idls/liqsol_core.json" } @@ -364,7 +365,9 @@ describe("Steps.processes.nodeop", () => { "--batch-enabled", "true", "--batch-operator-account", - "wireno.batchopaaaa" + "wireno.batchopaaaa", + "--outpost-ethereum-client-config-file", + artifactsFixture.ethereumClientConfigurationFile ]) ) // The depot matches this argv against `sysio.opreg::operators`, which is @@ -385,7 +388,9 @@ describe("Steps.processes.nodeop", () => { "--underwriter-enabled", "true", "--underwriter-account", - "wireno.underwriteraaaa" + "wireno.underwriteraaaa", + "--outpost-ethereum-client-config-file", + artifactsFixture.ethereumClientConfigurationFile ]) ) // Same chain-boundary rule as `--batch-operator-account`. diff --git a/packages/cluster-tool/tests/tools/wire/OperatorDaemonTool.test.ts b/packages/cluster-tool/tests/tools/wire/OperatorDaemonTool.test.ts index 847b0c101..f8fa219d5 100644 --- a/packages/cluster-tool/tests/tools/wire/OperatorDaemonTool.test.ts +++ b/packages/cluster-tool/tests/tools/wire/OperatorDaemonTool.test.ts @@ -11,7 +11,11 @@ import { } from "@wireio/cluster-tool/cluster/processes" import { OperatorDaemonTool } from "@wireio/cluster-tool/tools/wire" import { KeyGenerator } from "@wireio/cluster-tool/clients/wire" -import { ClusterConfigProvider, NodeRole } from "@wireio/cluster-tool/config" +import { + AnvilEthereumTransactionPolicyConfig, + ClusterConfigProvider, + NodeRole +} from "@wireio/cluster-tool/config" import { AWSAccountName, SignatureProviderType @@ -67,6 +71,7 @@ const artifacts: OperatorDaemonArtifacts = { OperatorRegistry: "0x3333333333333333333333333333333333333333", ReserveManager: "0x4444444444444444444444444444444444444444" }, + ethereumClientConfigurationFile: "/cluster/data/ethereum-client.json", solanaProgramId: "GrqvbZLCLkfeSQqvE7rL8XKHVWjNhAG2faLsY8yr9tD5", solanaIdlFile: "/cluster/data/solana-idls/liqsol_core.json" } @@ -207,7 +212,7 @@ describe("OperatorDaemonTool", () => { ).toBe(ExternalChainId) }) - it("carries the resolved endpoints into the daemon argv (batch + underwriter)", () => { + it("carries the shared Ethereum configuration into both daemon argvs", () => { const network = OperatorDaemonTool.networkFromConfig( fixtureConfig({ externalOutposts: externalOutposts( @@ -229,15 +234,17 @@ describe("OperatorDaemonTool", () => { network, keySourceFor ) - expect(valuesOf(batchArgs, "--outpost-ethereum-client")).toEqual([ - `eth-default,eth-wireno.batchopcccc,${ExternalEthereumRpcUrl},${ExternalChainId}` + expect(valuesOf(batchArgs, "--outpost-ethereum-client")).toEqual([]) + expect(valuesOf(batchArgs, "--outpost-ethereum-client-config-file")).toEqual([ + artifacts.ethereumClientConfigurationFile ]) expect(valuesOf(batchArgs, "--outpost-solana-client")).toEqual([ `sol-default,sol-wireno.batchopcccc,${ExternalSolanaRpcUrl}` ]) - expect(valuesOf(underwriterArgs, "--outpost-ethereum-client")).toEqual([ - `eth-default,eth-${underwriter.account},${ExternalEthereumRpcUrl},${ExternalChainId}` - ]) + expect(valuesOf(underwriterArgs, "--outpost-ethereum-client")).toEqual([]) + expect( + valuesOf(underwriterArgs, "--outpost-ethereum-client-config-file") + ).toEqual([artifacts.ethereumClientConfigurationFile]) expect(valuesOf(underwriterArgs, "--outpost-solana-client")).toEqual([ `sol-default,sol-${underwriter.account},${ExternalSolanaRpcUrl}` ]) @@ -271,7 +278,11 @@ describe("OperatorDaemonTool", () => { expect.objectContaining({ operator, node: expect.objectContaining({ role: NodeRole.batch_operator }), - extraArgs: expect.arrayContaining(["--batch-enabled"]) + extraArgs: expect.arrayContaining([ + "--batch-enabled", + "--outpost-ethereum-client-config-file", + artifacts.ethereumClientConfigurationFile + ]) }) ) // A flow-provisioned daemon launches in the BOOTSTRAP form — the @@ -305,7 +316,11 @@ describe("OperatorDaemonTool", () => { expect.objectContaining({ operator, node: expect.objectContaining({ role: NodeRole.underwriter }), - extraArgs: expect.arrayContaining(["--underwriter-enabled"]) + extraArgs: expect.arrayContaining([ + "--underwriter-enabled", + "--outpost-ethereum-client-config-file", + artifacts.ethereumClientConfigurationFile + ]) }) ) expect(recoverySpy.mock.calls[0][1].postBootstrap).toBeUndefined() @@ -347,10 +362,12 @@ describe("OperatorDaemonTool", () => { expect(providers[0]).toBe( "wire-PUB_K1_batchopaaaa,wire,wire,PUB_K1_batchopaaaa,KEY:PVT_K1_batchopaaaa" ) - // + the ETH and SOL outpost providers, named per-operator + // + the ETH and SOL outpost providers. The Ethereum id is process-local expect(providers.length).toBe(3) - // Provider NAMES are built from the CHAIN account, not the durable handle. - expect(providers[1]).toMatch(/^eth-wireno\.batchopaaaa,ethereum,ethereum,0x[0-9a-fA-F]{128},KEY:0x/) + // and stable because the shared config file names it. + expect(providers[1]).toMatch( + /^eth-default,ethereum,ethereum,0x[0-9a-fA-F]{128},KEY:0x/ + ) expect(providers[2]).toMatch(/^sol-wireno\.batchopaaaa,solana,solana,/) }) @@ -364,8 +381,9 @@ describe("OperatorDaemonTool", () => { expect(valuesOf(args, "--batch-epoch-poll-ms")).toEqual([String(OperatorDaemonTool.BatchEpochPollMs)]) expect(valuesOf(args, "--batch-delivery-timeout-ms")).toEqual([String(OperatorDaemonTool.BatchDeliveryTimeoutMs)]) expect(valuesOf(args, "--ext-debugging-server")).toEqual([network.debuggingServerUrl]) - expect(valuesOf(args, "--outpost-ethereum-client")).toEqual([ - `eth-default,eth-${operator.account},${network.ethereumRpcUrl},31337` + expect(valuesOf(args, "--outpost-ethereum-client")).toEqual([]) + expect(valuesOf(args, "--outpost-ethereum-client-config-file")).toEqual([ + artifacts.ethereumClientConfigurationFile ]) expect(valuesOf(args, "--outpost-solana-client")).toEqual([ `sol-default,sol-${operator.account},${network.solanaRpcUrl}` @@ -472,6 +490,9 @@ describe("OperatorDaemonTool", () => { expect(valuesOf(args, "--solana-outpost-program-name")).toEqual([ SolanaOutpostProgramTool.ProgramName ]) + expect(valuesOf(args, "--outpost-ethereum-client-config-file")).toEqual([ + artifacts.ethereumClientConfigurationFile + ]) }) it("drops the external-debugging plugin AND --ext-debugging-server when the debugging server is disabled", () => { @@ -579,6 +600,52 @@ describe("OperatorDaemonTool", () => { address: "0xaaa0000000000000000000000000000000000aaa", abi: [{ type: "event", name: "OPPEnvelope" }] }) + expect(Path.basename(prepared.ethereumClientConfigurationFile)).toBe( + OperatorDaemonTool.EthereumClientConfigurationFilename + ) + expect( + JSON.parse( + Fs.readFileSync(prepared.ethereumClientConfigurationFile, "utf-8") + ) + ).toEqual({ + schema_version: 1, + clients: [ + { + connection: { + client_id: OperatorDaemonTool.EthereumClientId, + signature_provider_id: + OperatorDaemonTool.EthereumSignatureProviderId, + rpc_url: OperatorDaemonTool.networkFromConfig(ctx.config) + .ethereumRpcUrl + }, + chain_id: AnvilProcess.DefaultChainId, + transaction_policy: AnvilEthereumTransactionPolicyConfig.create() + } + ] + }) + const externalCtx = fixtureContext({ + ...ctx.config, + clusterPath: Path.join(dir, "external-cluster"), + externalOutposts: externalOutposts( + ExternalEthereumRpcUrl, + ExternalSolanaRpcUrl + ) + }) + await OperatorDaemonTool.runArtifactPreparation( + externalCtx, + null, + new AbortController().signal + ) + const { clients: [externalClient] } = JSON.parse( + Fs.readFileSync( + externalCtx.outputs.assert(OperatorDaemonArtifactsKey) + .ethereumClientConfigurationFile, + "utf-8" + ) + ) + expect(externalClient.connection.rpc_url).toBe(ExternalEthereumRpcUrl) + expect(externalClient.chain_id).toBe(ExternalChainId) + expect(externalClient.transaction_policy).toBeUndefined() }) it("rejects an IDL missing a daemon-invoked instruction (wrong/stale IDL guard)", async () => {