Skip to content
Draft
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
20 changes: 20 additions & 0 deletions packages/cluster-tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,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
Expand All @@ -244,6 +245,25 @@ 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.

Generated development-cluster files omit `transaction_policy`. Nodeop therefore
assigns its maximum-`uint256` default caps, preserving the pre-policy behavior
for local clusters. The schema supports an explicit nested policy when finite
cluster limits are wanted later. 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ export interface OperatorDaemonArtifacts {
readonly ethereumAbiFiles: string[]
/** Deployed Ethereum outpost addresses (from `outpost-addrs.json`). */
readonly ethereumAddresses: Record<string, string>
/** 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. */
readonly solanaIdlFile: string
}

/** Typed cross-step handle to the prepared {@link OperatorDaemonArtifacts}. */
export const OperatorDaemonArtifactsKey: OutputKey<OperatorDaemonArtifacts> = outputKey(
"cluster.operatorDaemonArtifacts",
"outpost deploy artifacts for operator daemon command lines (ETH ABIs + addrs, SOL program id + IDL)"
)
export const OperatorDaemonArtifactsKey: OutputKey<OperatorDaemonArtifacts> =
outputKey(
"cluster.operatorDaemonArtifacts",
"outpost artifacts for operator daemon command lines (ETH ABIs + client configs, SOL program id + IDL)"
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getLogger } from "../../logging/Logger.js"
import { NodeConfig, NodeRole } from "../../config/NodeConfig.js"
import { ClusterConfigProvider } from "../../config/ClusterConfigProvider.js"
import { NodeopProcess } from "../../cluster/processes/NodeopProcess.js"
import { EthereumClientConfiguration } from "../../tools/ethereum/EthereumClientConfiguration.js"
import { OperatorDaemonTool } from "../../tools/wire/OperatorDaemonTool.js"
import { ClusterBuildContext } from "../ClusterBuildContext.js"
import {
Expand Down Expand Up @@ -98,7 +99,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 => {
Expand Down Expand Up @@ -138,9 +141,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/<name>.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.
Expand Down Expand Up @@ -235,9 +239,26 @@ export namespace ExternalOutpostSteps {
)
)

const ethereumClientConfigurationFile = Path.join(
dataPath,
OperatorDaemonTool.EthereumClientConfigurationFilename
),
network = OperatorDaemonTool.networkFromConfig(ctx.config),
ethereumClientConfiguration = EthereumClientConfiguration.create({
clientId: OperatorDaemonTool.EthereumClientId,
signatureProviderId: OperatorDaemonTool.EthereumSignatureProviderId,
rpcUrl: network.ethereumRpcUrl,
chainId: network.ethereumChainId
})
Fs.writeFileSync(
ethereumClientConfigurationFile,
JSON.stringify(ethereumClientConfiguration, null, 2)
)

ctx.outputs.set(OperatorDaemonArtifactsKey, {
ethereumAbiFiles,
ethereumAddresses,
ethereumClientConfigurationFile,
solanaProgramId,
solanaIdlFile: idlFile
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import Assert from "node:assert"

/** Optional finite limits for one 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
}

/** Signing-capable connection fields kept separate from EVM policy metadata. */
export interface EthereumClientConnection {
readonly client_id: string
readonly signature_provider_id: string
readonly rpc_url: string
}

/** One EVM signing client in the unified nodeop Ethereum configuration. */
export interface EthereumClientEntry {
readonly connection: EthereumClientConnection
readonly chain_id: number
readonly transaction_policy?: EthereumTransactionPolicy
}

/** Versioned JSON file consumed by `--outpost-ethereum-client-config-file`. */
export interface EthereumClientConfigurationFile {
readonly schema_version: number
readonly clients: readonly EthereumClientEntry[]
}

const CanonicalPositiveDecimal = /^[1-9][0-9]*$/,
SafeIdentifier = /^[A-Za-z0-9._-]{1,64}$/,
MaximumUint32 = 2 ** 32 - 1,
MaximumUint256 = (1n << 256n) - 1n

/** Build and validate the single-client config used by one cluster operator daemon. */
export namespace EthereumClientConfiguration {
/** Schema revision emitted into every generated configuration file. */
export const SchemaVersion = 1

/** Inputs required to generate one EVM signing-client configuration. */
export interface CreateOptions {
readonly clientId: string
readonly signatureProviderId: string
readonly rpcUrl: string
readonly chainId: number
readonly transactionPolicy?: EthereumTransactionPolicy
}

/**
* Create one unified client file; omitted policy means nodeop's maximum-value defaults.
*
* @param options - Connection, chain, and optional finite-policy values.
* @return A validated protobuf-JSON-compatible configuration document.
*/
export function create(
options: CreateOptions
): EthereumClientConfigurationFile {
const client: EthereumClientEntry = {
connection: {
client_id: options.clientId,
signature_provider_id: options.signatureProviderId,
rpc_url: options.rpcUrl
},
chain_id: options.chainId,
...(options.transactionPolicy == null
? {}
: { transaction_policy: options.transactionPolicy })
}
const file: EthereumClientConfigurationFile = {
schema_version: SchemaVersion,
clients: [client]
}
assertValid(file)
return file
}

/**
* Assert a generated client file matches nodeop's strict schema and numeric domains.
*
* @param file - Generated document to validate before writing it to disk.
* @return Nothing; invalid documents throw an assertion error.
*/
export function assertValid(file: EthereumClientConfigurationFile): void {
Assert.equal(
file.schema_version,
SchemaVersion,
`Ethereum client configuration schema_version must be ${SchemaVersion}`
)
Assert.equal(
file.clients.length,
1,
"Operator daemon Ethereum configuration must contain exactly one client"
)

const [client] = file.clients
Assert.ok(
client.connection != null,
"Ethereum client connection must be present"
)
const { connection } = client
Assert.match(
connection.client_id,
SafeIdentifier,
"Ethereum client_id must be 1-64 ASCII letters, digits, '.', '_', or '-'"
)
Assert.ok(
connection.signature_provider_id.length > 0,
"Ethereum signature_provider_id must not be empty"
)
const rpcUrl = new URL(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) return
const policy = client.transaction_policy,
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
}
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 "./EthereumClientConfiguration.js"
Loading