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
19 changes: 19 additions & 0 deletions packages/cluster-tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,32 @@ After `create` completes:
├── anvil/ # anvil state + pid + logs (if --ethereum-path)
├── solana_validator/ # solana-test-validator ledger + pid + logs (if --solana-path)
├── eth-abis/ # deployed contract ABIs (OPP, OPPInbound, BAR)
├── ethereum-client.json # shared unified Ethereum client config for operator daemons
├── solana-idls/ # Anchor IDLs (opp_outpost.json)
├── opp-debugging/ # envelope .data/.metadata pairs (debugging server writes)
└── tui/logs/ # wire-debugging-client-tool-tui writes here (see companion)
```

Every managed process writes `<dataPath>/<label>.pid` (e.g. `data/node_00/node-00.pid`) on spawn and removes it on clean exit. The TUI's Process Monitor iterates these.

### 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 versioned
file combines the stable `eth-default` client id and signature-provider id with
the Anvil RPC URL and chain id. 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
@@ -0,0 +1,130 @@
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
}

/** One connection in the unified nodeop Ethereum client configuration. */
export interface EthereumClientEntry {
readonly client_id: string
readonly signature_provider_id: string
readonly rpc_url: string
readonly chain_id: string
readonly transaction_policy?: EthereumTransactionPolicy
}

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

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

/** Build and validate the single-client config used by one cluster operator daemon. */
export namespace EthereumClientConfiguration {
export const SchemaVersion = 1

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. */
export function create(
options: CreateOptions
): EthereumClientConfigurationFile {
const client: EthereumClientEntry = {
client_id: options.clientId,
signature_provider_id: options.signatureProviderId,
rpc_url: options.rpcUrl,
chain_id: String(options.chainId),
...(options.transactionPolicy == null
? {}
: { transaction_policy: options.transactionPolicy })
}
const file: EthereumClientConfigurationFile = {
version: SchemaVersion,
clients: [client]
}
assertValid(file)
return file
}

/** Assert a generated client file matches nodeop's strict schema and numeric domains. */
export function assertValid(file: EthereumClientConfigurationFile): void {
Assert.equal(
file.version,
SchemaVersion,
`Ethereum client configuration version must be ${SchemaVersion}`
)
Assert.equal(
file.clients.length,
1,
"Operator daemon Ethereum configuration must contain exactly one client"
)

const [client] = file.clients
Assert.match(
client.client_id,
SafeIdentifier,
"Ethereum client_id must be 1-64 ASCII letters, digits, '.', '_', or '-'"
)
Assert.ok(
client.signature_provider_id.length > 0,
"Ethereum signature_provider_id must not be empty"
)
const rpcUrl = new URL(client.rpc_url)
Assert.ok(
rpcUrl.protocol === "http:" || rpcUrl.protocol === "https:",
"Ethereum rpc_url must use http or https"
)
const chainId = positiveUint(client.chain_id, "chain_id", MaximumUint32)

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(chainId > 0n)
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"
48 changes: 34 additions & 14 deletions packages/cluster-tool/src/tools/wire/OperatorDaemonTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down Expand Up @@ -41,6 +41,7 @@ import {
} from "../../orchestration/outputs/OperatorDaemonArtifacts.js"
import { Report } from "../../report/Report.js"
import { StepExtraRecorder } from "../../report/tools/StepExtraRecorder.js"
import { EthereumClientConfiguration } from "../ethereum/EthereumClientConfiguration.js"
import { SolanaOutpostProgramTool } from "../solana/SolanaOutpostProgramTool.js"
import { mkdirs } from "../../utils/fsUtils.js"
import { scaleTimeoutMs } from "../../utils/asyncUtils.js"
Expand Down Expand Up @@ -84,6 +85,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. */
Expand Down Expand Up @@ -118,6 +121,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). */
Expand Down Expand Up @@ -151,8 +156,9 @@ export namespace OperatorDaemonTool {
* Prepare the artifacts every operator daemon's command line references:
* generate `<dataPath>/eth-abis/<Name>.json` (`{contractName, address, abi}`,
* from the wire-ethereum hardhat artifacts + `outpost-addrs.json`), copy the
* `liqsol_core` (OPP outpost) IDL to `<dataPath>/solana-idls/`, resolve the SOL program id,
* and store the typed {@link OperatorDaemonArtifacts}. Runs ONCE, after both
* `liqsol_core` (OPP outpost) IDL to `<dataPath>/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<
Expand Down Expand Up @@ -254,9 +260,27 @@ 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
),
ethereumClientConfiguration = EthereumClientConfiguration.create({
clientId: EthereumClientId,
signatureProviderId: EthereumSignatureProviderId,
rpcUrl: networkFromConfig(ctx.config).ethereumRpcUrl,
chainId: AnvilProcess.DefaultChainId
})
Fs.writeFileSync(
ethereumClientConfigurationFile,
JSON.stringify(ethereumClientConfiguration, null, 2)
)

ctx.outputs.set(OperatorDaemonArtifactsKey, {
ethereumAbiFiles,
ethereumAddresses,
ethereumClientConfigurationFile,
solanaProgramId,
solanaIdlFile
})
Expand All @@ -265,9 +289,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
})
Expand All @@ -292,27 +317,22 @@ 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
): string[] {
const ethereumProvider = `eth-${operator.account}`,
const ethereumProvider = EthereumSignatureProviderId,
solanaProvider = `sol-${operator.account}`
return [
...pair(
"--signature-provider",
KeyGenerator.toSignatureProvider(operator.ethereum, ethereumProvider)
),
...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)
Expand Down Expand Up @@ -362,7 +382,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: <chain_code>,<opp_addr>,<opp_inbound_addr>
// SVM: <chain_code>,<opp_outpost_program_id>
...pair(
Expand Down
Loading