diff --git a/CLAUDE.md b/CLAUDE.md
index d16e4c9..92b3eb2 100755
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,13 +60,13 @@ Important behavior of the entry point (`src/index.ts`): after running the comman
Validated at startup in `createCLI()` (`src/cli.ts`), which `process.exit(1)`s with a red message if missing:
- `PRIVATE_KEY` **or** `MNEMONIC` — signer credentials (private key preferred; mnemonic via `ethers.Wallet.fromPhrase`).
-- `RPC` — JSON-RPC endpoint; chainId is read from `provider.getNetwork()`, not configured manually.
+- `RPC` — JSON-RPC endpoint(s). **Either** a single URL (legacy, chainId read from `provider.getNetwork()`) **or** a JSON map keyed by chainId whose values are a URL or an ordered list of URLs (e.g. `{"1":"https://…","8453":["https://a","https://b"]}`). A chain with ≥2 URLs is served by an ethers v6 `FallbackProvider` (`quorum:1`, priority = declaration order, per-backend `stallTimeout`). The shape is parsed and validated up front in `createCLI()` (a malformed value `exit(1)`s with an example); the unset message stays the test-asserted `"Have you forgot to set env RPC?"`. All RPC/provider/signer/config lifecycle lives in `src/rpcRegistry.ts` (the single source of truth, mirroring `nodeConnection.ts`); `initializeSigner()` is a thin wrapper over it. A default/active chain is resolved (`setChain`/`CHAIN_ID` → persisted default → sole configured chain → the single chain both the node serves and the registry knows → none); chain-explicit and compute commands additionally accept `--chainId` (see "CLI commands exposed" and "Compute flow"). Runtime-added chains persist to `~/.ocean/cli/rpc.json` (override with `RPC_CONFIG_FILE`); env `RPC` is merged first and wins on conflict.
### Optional environment variables
- `NODE_URL` — the **initial** Ocean Node. An `http(s)://` URL, a raw libp2p peer id, or a full `/dns4/.../p2p/...` multiaddr. **Not required to start:** without it the CLI runs in a node-less state where the `preAction` gate in `createCLI()` refuses every command except `setNode` / `getNode` / `help` (see "Node selection"). Switchable at runtime with `setNode`.
- `DISABLE_P2P` — `true` skips starting libp2p entirely. Combined with a P2P `NODE_URL` it is a fatal contradiction (`exit(1)` at startup).
-- `ADDRESS_FILE` — path to a contracts `address.json`. Defaults to `${homedir}/.ocean/ocean-contracts/artifacts/address.json`. Needed by escrow / mint / access-list commands (see "Config & chain selection").
+- `ADDRESS_FILE` — path to a contracts `address.json`. Defaults to `${homedir}/.ocean/ocean-contracts/artifacts/address.json`. Consumed by ocean.js `ConfigHelper` for **Barge / custom-deployed** contract addresses. No longer strictly required for escrow / access-list: `ConfigHelper` falls back to the multi-chain contract set bundled with `@oceanprotocol/lib`, so those commands now work on supported public chains without a local `address.json`. `mintOcean` additionally needs an Ocean token address — from `config.oceanTokenAddress` (absent on some chains, e.g. Base) or an explicit `--token
` (see "Config & chain selection").
- `INDEXING_MAX_RETRIES` / `INDEXING_RETRY_INTERVAL` — how long to wait for an asset to be indexed. **Code defaults are 120 retries × 4000 ms** (`getIndexingWaitSettings()` in `helpers.ts`); the README's "100 / 3000" figures are stale.
- `AVOID_LOOP_RUN` — `true` = one-shot (no REPL loop). Unset/`false` = interactive loop.
- `BOOTSTRAP_PEERS` — comma-separated extra libp2p multiaddrs, added to the bootstrap list built in `nodeConnection.ts`.
@@ -83,11 +83,14 @@ All registered in `src/cli.ts` via Commander (`commander` v13). Every command su
- Persistent storage buckets: `createBucket`, `addFileToBucket`, `listBuckets`, `listFilesInBucket`, `getFileObject`, `deleteFile`.
- Admin: `downloadNodeLogs`.
- Node selection: `setNode` (alias `useNode`), `getNode` (alias `currentNode`).
+- Chain/RPC management: `addChain` (alias `addRpc`), `removeChain` (alias `removeRpc`), `listChains` (aliases `getRpcs`/`chains`), `setChain` (alias `useChain`), `getChain` (alias `currentChain`). Node-free (in `NODE_FREE_COMMANDS`); implemented directly in `cli.ts` actions (no `Commands` instance), mutating the `rpcRegistry` singleton the way `setNode`/`getNode` mutate `process.env.NODE_URL`.
- `help` / `h`.
+**`--chainId` routing.** Chain-explicit commands (`mintOcean`, all escrow, all access-list, and the escrow-paid `startService`/`extendService`) take a single `--chainId ` option: flag → default chain → error listing configured chains. Chain-implied commands (`publish`/`publishAlgo`/`editAsset`/`allowAlgo`/`download`) route to the **DDO's own `chainId`** (via `Commands.useChain` / `routeToAssetChain`). Category-agnostic commands sign on the default chain. Services are **single-chain** (no asset DDO), so `--chainId` is just the payment/escrow chain and must also be one the compute env prices on (`env.fees[chainId]`).
+
Per-command flags and examples are exhaustively documented in `README.md` ("Command Usage" / "Available Named Options Per Command"). A few load-bearing notes:
-- `startCompute` requires `maxJobDuration` (seconds, drives payment), `paymentToken` (must be listed by the chosen compute env — get it from `getComputeEnvironments`), and `resources` (stringified JSON like `'[{"id":"cpu","amount":3},{"id":"ram","amount":16772672536},{"id":"disk","amount":0}]'`). `--accept true` skips the interactive payment confirmation prompt (mandatory when stdin is not a TTY). Optional `--output` is a stringified JSON remote-storage backend (S3/FTP/URL/Arweave/IPFS); omit to store results on the node's disk.
+- `startCompute` requires `maxJobDuration` (seconds, drives payment), `paymentToken` (must be listed by the chosen compute env for the payment chain — get it from `getComputeEnvironments`, which now prints fee chains + tokens per env), and `resources` (stringified JSON like `'[{"id":"cpu","amount":3},{"id":"ram","amount":16772672536},{"id":"disk","amount":0}]'`). Optional `--chainId` is the **payment/escrow chain** (flag → default → error); each dataset/algorithm is still ordered on its own DDO chain, so a job can mix asset chains and pay on another — every chain used must be a registered RPC. `--accept true` skips the interactive payment confirmation prompt (mandatory when stdin is not a TTY). Optional `--output` is a stringified JSON remote-storage backend (S3/FTP/URL/Arweave/IPFS); omit to store results on the node's disk.
- Datasets/algorithm arguments accept a DID, a JSON `ComputeAsset`/`ComputeAlgorithm` with a `fileObject` (raw, unpublished, no datatoken order), a JSON array, mixed DID+raw entries, or the legacy `[did:a,did:b]` form. When passing JSON on the shell, single-quote it and use `-- ` to stop Commander option parsing.
- `startFreeCompute` targets a compute env with `free === true` and does no ordering/payment.
@@ -117,10 +120,36 @@ One big class holding all command logic. The constructor:
- creates `this.aquarius = new Aquarius(this.oceanNodeUrl)` (the Ocean Node also serves the Aquarius/indexer API),
- loads `this.indexingParams` from `getIndexingWaitSettings()`.
-### Config & chain selection — two mechanisms (important)
-
-1. **`ConfigHelper().getConfig(chainId)`** from ocean.js — used as `this.config` for the general publish/consume/compute flows.
-2. **`getConfigByChainId(chainId)`** in `helpers.ts` — reads the local `ADDRESS_FILE` (`address.json`), finds the network entry whose `chainId` matches, and returns its contract addresses. This is the source of `Ocean` (mintOcean), `Escrow` (all escrow commands), and `AccessListFactory` (createAccessList) addresses. **These commands therefore require a local `address.json`** (i.e. a Barge / local-contracts deployment) and will fail if the chain isn't present in that file. Chain selection is otherwise implicit — derived from the RPC's network, never passed as a flag.
+### Config & chain selection — one mechanism (important)
+
+**`ConfigHelper().getConfig(chainId)`** from ocean.js is the single source of both the general
+publish/consume/compute config (`this.config`) **and** the contract addresses. The CLI's old
+hand-rolled `getConfigByChainId()` (which parsed a Barge-only `address.json` and returned the
+capitalized `Ocean`/`Escrow`/`AccessListFactory` keys) has been **deleted**. All address reads now
+go through `getConfigFor(chainId)` in `src/rpcRegistry.ts` (a memoized `ConfigHelper().getConfig`
+with `nodeUri` set) and use the lib's **lowercase** field names:
+
+- `config.oceanTokenAddress` — `mintOcean` (resolved as `--token` flag → `oceanTokenAddress` → error asking for `--token`, since some chains e.g. Base have no bundled Ocean token).
+- `config.escrow` — all escrow commands **and** the on-demand-service escrow path (`startService`/`extendService`, via `serviceHelpers.ts`).
+- `config.accessListFactory` — `createAccessList`.
+
+`ConfigHelper` reads `ADDRESS_FILE` when set (Barge / custom) else the multi-chain contracts bundled
+with `@oceanprotocol/lib`, so these commands now work off-Barge. `requireAddress(chainId, field, label)`
+in `rpcRegistry.ts` centralizes the "address missing for this chain" error.
+
+**Multi-chain (the RPC registry).** `RPC` is now **either** a single URL (legacy, chainId probed via
+`getNetwork()`) **or** a JSON map `{ "": "url" | ["url", …] }`. `src/rpcRegistry.ts` is the
+single source of truth for all RPC/provider/signer/config lifecycle (mirrors `nodeConnection.ts`):
+`getProvider` builds a `JsonRpcProvider` (1 URL) or a `FallbackProvider` (`quorum:1`, priority = order,
+`stallTimeout`, `staticNetwork`; ≥2 URLs), `getSigner`/`getConfigFor` are memoized per chain, and
+`verifyChain` lazily checks each backend's real `eth_chainId` on first use (dropping confirmed
+mismatches, keeping merely-unreachable ones for failover). Runtime-added chains persist to
+`~/.ocean/cli/rpc.json` (override `RPC_CONFIG_FILE`); on load the env map is merged with that file and
+**env wins**. Default (active) chain resolution: `setChain`/`CHAIN_ID` → persisted default → sole
+configured chain → node∩registry. `Commands` pins the **default** chain in its constructor but routes
+per command: `useChain(chainId)` re-points `this.signer`/`this.config` (safe — a fresh instance per CLI
+invocation, methods run one at a time), `configFor`/`signerFor` are the per-chain accessors Phase 3
+uses directly. `destroyProviders()` tears providers down on exit (next to `stopP2P`).
### ocean.js integration and helpers (`src/helpers.ts`)
@@ -139,17 +168,19 @@ One big class holding all command logic. The constructor:
- **Publish** (`publish`, `publishAlgo`): read a JSON DDO file, then `createAssetUtil` with `asset.indexedMetadata.nft.name/symbol` and `asset.services[0].files.files`. `--encrypt` (default `true`) controls DDO encryption. See `metadata/*.json` for the expected DDO shape.
- **Edit** (`editAsset`): resolve the DDO via `waitForIndexer`, shallow-merge the top-level keys from the update JSON into the asset, then `updateAssetMetadata`.
- **allowAlgo / disallowAlgo**: mutate `services[0].compute.publisherTrustedAlgorithms` (checks signer is the NFT owner and the service is a `compute` service; computes container + files checksums via `ProviderInstance.checkDidFiles` / `getHash`) and re-publish metadata. (`disallowAlgo` exists on `Commands` but is not registered as a CLI command.)
-- **Download/consume** (`download`): resolve DDO → `orderAsset` (buys a datatoken) → `tx.wait()` → `ProviderInstance.getDownloadUrl` → `downloadFile` (streams to disk, filename from `content-disposition` when present).
+- **Download/consume** (`download`): resolve DDO → look up the target service by id (errors if the `serviceId` is not in the DDO, instead of silently falling back to `services[0]`) → for **DDO version ≥ 5.0.0** run a provider-initialize step (`Commands.initializeProvider` → `ProviderInstance.initialize`, plus an SSI/policy-server verification via `ProviderInstance.initializePSVerification` when `SSI_WALLET_API` is set) then fetch the policy-server object (`getPolicyServerOBJ`, which now returns `null` when the node reports the policy server is not configured) → `orderAsset` (buys a datatoken) → `tx.wait()` → `ProviderInstance.getDownloadUrl` → `downloadFile` (streams to disk, filename from `content-disposition` when present). Each step catches its own error, prints an actionable message, and returns rather than throwing.
### Compute flow
-The `startCompute` **action in `cli.ts`** orchestrates a two-phase flow (not a single `Commands` method):
+The `startCompute` **action in `cli.ts`** orchestrates a two-phase flow (not a single `Commands` method). It first resolves the **payment/escrow chain** with `resolveChainId(options.chainId)` (`--chainId` flag → default chain → error) and passes it as an explicit trailing param to both compute methods:
-1. `commands.initializeCompute([...])` — resolves inputs, fetches compute envs (`ProviderInstance.getComputeEnvironments`), matches the env by id, validates chainId/paymentToken/resources/maxJobDuration (capping to `env.maxJobDuration`), and returns the provider `initializeCompute` response (payment + provider fees).
+1. `commands.initializeCompute([...], paymentChainId)` — resolves inputs, fetches compute envs (`ProviderInstance.getComputeEnvironments`), matches the env by id, validates that the **payment chain** is in both `computeEnv.fees` and the RPC registry, plus paymentToken/resources/maxJobDuration (capping to `env.maxJobDuration`), and returns the provider `initializeCompute` response (payment + provider fees). Signs against the payment chain (`signerFor(paymentChainId)`).
2. Prints payment details, converts amount with `unitsToAmount`, and asks for confirmation unless `--accept true` (hard error on non-TTY).
-3. `commands.computeStart([...])` — orders the algorithm (if DID-based) and each DID-based dataset via `handleComputeOrder`, verifies escrow funds (`EscrowContract.verifyFundsForEscrowPayment`), then calls `ProviderInstance.computeStart` (C2D V2: all datasets passed together in `assets`; the old `additionalDatasets` param is unused). Prints `JobID` and the agreement id (`payment.lockTx`).
+3. `commands.computeStart([...], paymentChainId)` — **per-asset ordering**: each DID-based dataset/algorithm DDO is ordered on **its own** chain via a per-chain `{ signer, config, Datatoken }` context (`orderCtxFor(ddo.chainId)`, memoized within the call), replacing the old single `Datatoken` on the signer's one chain. Escrow funds (`EscrowContract.verifyFundsForEscrowPayment`), deposit/authorize, and `ProviderInstance.computeStart` all run on the **payment chain** (`paymentSigner` = `signerFor(paymentChainId)`), independent of where the assets live (C2D V2: all datasets passed together in `assets`; the old `additionalDatasets` param is unused). Prints `JobID` and the agreement id (`payment.lockTx`).
+
+**Multi-chain compute (category d).** A single job may mix datasets/algorithm on different chains and pay/escrow on yet another. `--chainId` is **only** the payment chain; asset chains are always taken from each DDO, never passed by the user. `Commands.ensureComputeChainsRegistered(paymentChainId, ddos, algoDdo)` validates up front — via the pure `computeJobChainIds()` helper (`helpers.ts`) — that every chain the job touches (payment + each DID asset/algo chain) has a registered RPC, and errors listing the missing chain(s) before any paid order. In the common **single-chain** case every asset shares the payment chain, so ordering/escrow/`computeStart` are equivalent to before.
-`startFreeCompute` → `freeComputeStart` calls `ProviderInstance.freeComputeStart` against a `free` env with no ordering/escrow. `stopCompute`, `getJobStatus`, `downloadJobResults`, `computeStreamableLogs`, `getComputeEnvironments` are thin wrappers over the corresponding `ProviderInstance` methods.
+`startFreeCompute` → `freeComputeStart` calls `ProviderInstance.freeComputeStart` against a `free` env with no ordering/escrow; its `--chainId` just routes the signing chain (`routeExplicit`/`useChain`, like a category (c) command). `stopCompute`, `getJobStatus`, `downloadJobResults`, `computeStreamableLogs` are thin wrappers over the corresponding `ProviderInstance` methods. `getComputeEnvironments` additionally prints a per-env **payment summary** (fee chains + accepted tokens, via the pure `summarizeComputeEnvFees()` helper) so `--chainId`/`--paymentToken` are choosable without reading raw JSON.
### Escrow, access lists, persistent storage, auth, node logs
@@ -198,8 +229,12 @@ CI (`.github/workflows/ci.yml`) has three jobs: `build`, `lint`, and `test_syste
## Notable gotchas
- ESM + `.js` import extensions are mandatory; forgetting them breaks the build/runtime.
-- Chain is inferred from the RPC network, never passed explicitly; escrow/mint/access-list commands additionally need the chain present in `address.json`.
-- The two config paths (`ConfigHelper` vs `getConfigByChainId`/`address.json`) are separate — a chain working for publish/consume can still fail escrow if it's missing from `address.json`.
+- `RPC` may be a single URL or a JSON map keyed by chainId; a chain with ≥2 URLs becomes a `FallbackProvider` (`quorum:1`). All RPC/provider/signer/config lifecycle lives in `src/rpcRegistry.ts`.
+- **Chain routing is per command, not global.** Chain-agnostic commands sign on the default chain; chain-implied commands (`publish`/`publishAlgo`/`editAsset`/`allowAlgo`/`download`) route to the **DDO's `chainId`** (fixing the old publish-ignores-DDO-chainId bug); chain-explicit commands (`mintOcean` + escrow + access-list + `startService`/`extendService`) take **`--chainId`** (flag → default → error). `Commands.useChain(chainId)` re-points `this.signer`/`this.config` (mutation is safe: one fresh instance per invocation, sequential methods). **Compute is the exception** — `startCompute` genuinely needs several chains inside one call, so it does **not** use `useChain`: it takes `configFor(chainId)`/`signerFor(chainId)` explicitly per asset (each DDO's chain) and per payment (`--chainId`). `startFreeCompute` does no ordering/escrow, so it needs only one signing chain and routes its `--chainId` through `useChain`.
+- **Default-chain resolution:** `setChain`/`CHAIN_ID` env → persisted default → sole configured chain → the one chain both the node serves and the registry knows. `getActiveChainId()` is the lenient variant (falls back to *any* registered chain for signing, without committing a default); `resolveChainId()`/`routeExplicit()` in `cli.ts` do the strict flag→default→error for `--chainId`.
+- **Runtime chains persist** to `~/.ocean/cli/rpc.json` (`RPC_CONFIG_FILE` override); env `RPC` is merged with it and **env wins**. `addChain`/`removeChain`/`setChain` rewrite the file; all persistence I/O is defensive (a failure warns, never breaks a command).
+- The five chain commands (`addChain`/`removeChain`/`listChains`/`setChain`/`getChain`) are in `NODE_FREE_COMMANDS` **and** a `HELP_GROUPS` entry — `assertHelpGroupsCoverAll` fails startup if a registered command is ungrouped.
+- Contract addresses come **only** from ocean.js `ConfigHelper` (via `getConfigFor`), using lowercase keys (`escrow`/`accessListFactory`/`oceanTokenAddress`). The old CLI-local `getConfigByChainId` is gone. Addresses resolve from `ADDRESS_FILE` (Barge/custom) else the bundled multi-chain set; a chain with no address for the needed contract errors via `requireAddress`.
- The 1-indexed vs 0-indexed args-array split between `Commands` methods is easy to get wrong when adding/renaming commands.
- Running the CLI without `AVOID_LOOP_RUN=true` drops into a stdin REPL after the first command — surprising in scripts.
- `fixAndParseProviderFees` is a regex JSON patcher for the initialize→start round trip; prefer fixing the data shape over extending the regex.
diff --git a/README.md b/README.md
index ce05755..1b6cb1d 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,43 @@ export MNEMONIC="XXXX"
export RPC='XXXX'
```
+`RPC` accepts **either** a single URL (unchanged, legacy behaviour) **or** a JSON map keyed by
+chainId, where each chain's value is one URL or an ordered list of URLs:
+
+```bash
+export RPC='http://localhost:8545' # single URL
+export RPC='{"1":"https://eth.example","8453":["https://a","https://b"]}'
+```
+
+When a chain lists **two or more** URLs they are used as an ethers v6 `FallbackProvider`
+(`quorum: 1`, declaration order = preference, a slow endpoint hands off to the next), so a single
+dead endpoint no longer breaks the session. A malformed `RPC` value fails fast at startup with a
+message showing the expected shape. Contract addresses (escrow / access-list factory / Ocean
+token) are resolved by ocean.js `ConfigHelper` — from `ADDRESS_FILE` when set (Barge / custom
+deployments) else the multi-chain set bundled with the library — so escrow / access-list now work
+off-Barge on any supported chain without a local `address.json`. `mintOcean` additionally needs a
+configured `oceanTokenAddress` or an explicit `--token ` (some chains, e.g. Base, ship no
+bundled Ocean token).
+
+**Multiple chains, added at runtime, and the default chain.** When `RPC` lists more than one chain
+you can also register/unregister chains at runtime with [`addChain`](#chain-management) /
+`removeChain`, list them with `listChains`, and pick the **default (active) chain** with `setChain`.
+Runtime-added chains are persisted to `~/.ocean/cli/rpc.json` (override with `RPC_CONFIG_FILE`, same
+JSON-map shape, hand-editable) so they survive a restart; on load the env `RPC` is merged with that
+file and **env wins on conflict**. The default chain is resolved as: `setChain` / `CHAIN_ID` env →
+persisted default → the sole configured chain → the one chain both the node serves and the registry
+knows. Commands then pick their chain as follows:
+
+- **Chain-agnostic** commands (reads, jobs, storage, auth) just sign on the default (or any
+ registered) chain. The node/chain-management commands (`setNode`/`getNode`, `addChain`,
+ `removeChain`, `listChains`, `setChain`, `getChain`) are node-free registry operations that do not
+ sign at all.
+- **Chain-implied** commands (`publish`, `publishAlgo`, `editAsset`, `allowAlgo`, `download`) use the
+ **DDO's own `chainId`** — publishing now honours the `chainId` in your metadata file.
+- **Chain-explicit** commands (`mintOcean`, all escrow, all access-list, and the escrow-paid
+ `startService` / `extendService`) take a single **`--chainId `** flag; without it they fall back
+ to the default chain, and error (listing the configured chains) when there is none.
+
- Optional (but recommended), set an Ocean Node URL. Ocean Nodes infrastructure is responsible for handling assets indexing and metadata caching. It replaced old Provider and Aquarius standalone apps.
```
@@ -220,7 +257,24 @@ Notes when switching nodes:
- **Compute jobs live on the node that started them.** After a switch, `getJobStatus` / `downloadJobResults` query the *new* node — switch back to look up older jobs.
- **For a node on your own machine, prefer the full multiaddr** (`/ip4/127.0.0.1/tcp/9001/ws/p2p/`) over a bare peer id: a bare id has to be found via DHT, which may not advertise localhost addresses.
- **In one-shot mode** (`AVOID_LOOP_RUN='true'`) `setNode` only validates the node and prints the result — the switch dies with the process. Use `NODE_URL` for one-shot runs.
-- `chainId` still comes from `RPC`, never from the node. `setNode` warns when the node does not serve the chain your RPC is on.
+- `chainId` still comes from `RPC`, never from the node. `setNode` warns when the node does not serve the chain your RPC is on. `getNode` also flags any chain the node serves for which **no RPC is configured**.
+
+---
+
+
+
+**Chain / RPC management** (node-free — these work before `setNode` picks a node):
+
+- **Register a chain at runtime:**
+ `npm run cli addChain 137 https://polygon-rpc.com` (alias `addRpc`; positional or `--chainId`/`--url`). Give more than one URL for a `FallbackProvider`: `addChain 137 https://a https://b`. Each URL is verified to actually serve the given chain (a URL on a different chain is rejected), then the chain is persisted to `~/.ocean/cli/rpc.json`.
+- **Unregister a chain:**
+ `npm run cli removeChain 137` (alias `removeRpc`). Refuses to remove the only configured chain; clears the default if it pointed there.
+- **List configured chains:**
+ `npm run cli listChains` (aliases `getRpcs`, `chains`) — prints each chain, its URLs, which is the default, and (if a node is set) which chains the node serves / lacks an RPC for.
+- **Set / show the default chain:**
+ `npm run cli setChain 137` (alias `useChain`) sets the default (must be registered; persisted). `npm run cli getChain` (alias `currentChain`) prints it.
+
+`--chainId` on the chain-explicit commands overrides the default for a single command; `CHAIN_ID` env sets it for the session; `setChain` persists it.
---
@@ -281,7 +335,9 @@ Notes when switching nodes:
(Order of `--did` and `--folder` does not matter.)
- **Rules:**
- serviceId is optional. If omitted, the CLI defaults to the first available download service.
+ serviceId is optional. If omitted, the CLI defaults to the first service listed in the DDO (`services[0]`). If you pass a `serviceId` that does not exist in the DDO, the command now fails fast with a clear error instead of silently ordering the first service.
+
+ For **v5 DDOs** (version ≥ 5.0.0) the download first runs a provider-initialization step against the asset's service endpoint. When `SSI_WALLET_API` is set (see the env vars above) this also performs the SSI / policy-server verification flow; when the target node reports it has no policy server configured, that step is skipped automatically.
---
@@ -299,7 +355,8 @@ Notes when switching nodes:
- `maxJobDuration` is a required parameter an represents the time measured in seconds for job maximum execution, the payment is based on this maxJobDuration value, user needs to provide this.
-- `paymentToken` is required and represents the address of the token that is supported by the environment for processing the compute job payment. It can be retrieved from `getComputeEnvironments` command output.
+- `--chainId` is optional and selects the **payment/escrow chain** for the job (flag → active/default chain → error). It is independent of where the assets live: each dataset and the algorithm are ordered on **their own** DDO chain, so one job can span multiple chains and pay on another. Every chain the job touches (the payment chain plus each asset's chain) must be a registered RPC — add any missing one with `addChain `. In the common single-chain case you can omit `--chainId` entirely.
+- `paymentToken` is required and represents the address of the token that is supported by the environment **on the payment chain** for processing the compute job payment. It can be retrieved from `getComputeEnvironments` command output, which lists the fee chains and their accepted tokens per environment.
- `resources` is required and represents a stringified JSON object obtained from `getComputeEnvironments` command output. `getComputeEnvironments` command shows the available resources and the selected resources by the user need to be within the available limits.
e.g.: `'[{"id":"cpu","amount":3},{"id":"ram","amount":16772672536},{"id":"disk","amount":0}]'`
- `--accept` option can be set to `true` or `false`. If it is set to `false` a prompt will be displayed to the user for manual accepting the payment before starting a compute job. If it is set to `true`, the compute job starts automatically, without user input.
@@ -334,6 +391,7 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor
(Options can be provided in any order.)
- `output` is an optional stringified JSON object specifying a remote storage backend where job results will be uploaded. Same format as `startCompute`.
+- `--chainId` is optional. A free environment does no ordering or payment, so this only selects which registered chain the request is signed on (flag → active/default chain). Omit it to use the active chain.
- Like `startCompute`, the datasets and algorithm arguments accept raw `ComputeAsset`/`ComputeAlgorithm` JSON objects with a `fileObject` (no DID), and mixed DID + raw datasets. e.g.:
`npm run cli startFreeCompute did:op:dataset '{"fileObject":{"type":"url","url":"https://example.com/algo.py","method":"GET"},"meta":{"container":{"entrypoint":"python $ALGO","image":"oceanprotocol/algo_dockers","tag":"python-branin","checksum":"sha256:..."}}}' env1`
@@ -354,6 +412,8 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor
`npm run cli getComputeEnvironments`
+ Prints, per environment, a **payment summary** — whether it is free, and for a paid env each fee chain with its accepted payment-token addresses — so you can pick `--chainId` and `--paymentToken` for `startCompute` without reading the raw JSON (the full JSON is still printed below the summary).
+
Optionally pass a specific Ocean Node URL or peer id to query instead of `NODE_URL`:
`npm run cli getComputeEnvironments `
@@ -761,6 +821,7 @@ Notes:
`--maxJobDuration `
`-t, --token `
`--resources `
+ `--chainId ` (Optional. Payment/escrow chain; flag → active/default chain → error. Assets are still ordered on their own DDO chains.)
`--amountToDeposit ` (Id `''`, it will fallback to initialize compute payment amount.)
`-o, --output [output]` (Optional. Stringified JSON object specifying a remote storage backend for job results.)
`-s, --services [serviceIds]` (Optional, comma-separated; must match datasetDids length, positional 1–1)
@@ -773,6 +834,7 @@ Notes:
`-o, --output [output]` (Optional. Stringified JSON object specifying a remote storage backend for job results.)
`-s, --services [serviceIds]` (Optional, comma-separated; must match datasetDids length, positional 1–1)
`-x, --algo-service [algoServiceId]` (Optional, override algorithm service)
+ `--chainId ` (Optional. Chain to sign the free request on; free envs do no ordering/payment.)
- **getComputeEnvironments:**
`-n, --node [node]` (Optional. Ocean Node URL or peer id to query; defaults to `NODE_URL`)
diff --git a/src/cli.ts b/src/cli.ts
index b3e7c60..33fe40e 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,6 +1,5 @@
import { Command } from "commander";
import { Commands } from "./commands.js";
-import { JsonRpcProvider, Signer, ethers } from "ethers";
import fs from "fs";
import { createRequire } from "module";
import chalk from "chalk";
@@ -30,6 +29,19 @@ import {
SearchFlags,
} from "./searchResourcesHelpers.js";
import { interactiveResourceSearch } from "./searchResourcesFlow.js";
+import {
+ loadRegistry,
+ getActiveChainId,
+ getSigner,
+ parseRpcEnv,
+ getDefaultChainId,
+ setDefaultChainId,
+ resolveDefaultChain,
+ hasChain,
+ listChains,
+ addChain,
+ removeChain,
+} from "./rpcRegistry.js";
// Commands usable before any Ocean Node is selected. Everything else is refused by the
// preAction gate below until `setNode` succeeds. Canonical names only — aliases
@@ -42,6 +54,12 @@ const NODE_FREE_COMMANDS = new Set([
// A network-wide DHT search — a natural way to *find* a node to select, so it must work
// before `setNode` picks one.
"searchComputeResources",
+ // Chain/RPC management is independent of the node and must work before one is chosen.
+ "addChain",
+ "removeChain",
+ "listChains",
+ "setChain",
+ "getChain",
]);
// Topic grouping for the help listing. Purely presentational: it only changes how the command
@@ -67,6 +85,16 @@ const HELP_GROUPS: HelpGroup[] = [
heading: "Discover compute providers",
commands: ["searchComputeResources", "getComputeEnvironments"],
},
+ {
+ heading: "Chains & RPC",
+ commands: [
+ "addChain",
+ "removeChain",
+ "listChains",
+ "setChain",
+ "getChain",
+ ],
+ },
{
heading: "Assets — publish, edit, consume",
commands: ["publish", "publishAlgo", "editAsset", "allowAlgo", "getDDO", "download"],
@@ -246,18 +274,63 @@ async function runResourceWizard(chainId: number) {
return interactiveResourceSearch(chainId);
}
+// Thin wrapper over the RPC registry: seed it from the `RPC` env, resolve the single
+// active (default) chain, and return that chain's memoized signer. For legacy
+// single-URL users the chainId is still discovered by probing getNetwork() and nothing
+// about their behavior changes; multi-URL/JSON-map users get a FallbackProvider.
async function initializeSigner() {
- const provider = new JsonRpcProvider(process.env.RPC);
- let signer: Signer;
+ loadRegistry();
+ // Lenient: the real default when there is one, else any registered chain purely to
+ // obtain a signer for chain-agnostic commands (plan §"Default chain" step 4).
+ const chainId = await getActiveChainId();
+ const signer = await getSigner(chainId);
+ return { signer, chainId };
+}
- if (process.env.PRIVATE_KEY) {
- signer = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
- } else {
- signer = ethers.Wallet.fromPhrase(process.env.MNEMONIC, provider);
+// Resolve the chain for a chain-explicit command (category c): the `--chainId` flag →
+// the default chain → a clear error. Validates the flag is a registered positive integer.
+function resolveChainId(flag?: string | number): number {
+ if (flag !== undefined && flag !== null && `${flag}`.trim() !== "") {
+ const id = Number(flag);
+ if (!Number.isInteger(id) || id <= 0) {
+ throw new Error(`Invalid --chainId "${flag}": must be a positive integer.`);
+ }
+ if (!hasChain(id)) {
+ throw new Error(
+ `Chain ${id} is not configured. Configured chains: ${
+ listChains()
+ .map((c) => c.chainId)
+ .join(", ") || "none"
+ }. Add it with 'addChain '.`,
+ );
+ }
+ return id;
}
+ const def = getDefaultChainId();
+ if (def !== undefined) return def;
+ throw new Error(
+ `No chain specified and no default chain is set. Pass --chainId , or run 'setChain '. Configured chains: ${
+ listChains()
+ .map((c) => c.chainId)
+ .join(", ") || "none"
+ }.`,
+ );
+}
- const { chainId } = await signer.provider.getNetwork();
- return { signer, chainId: Number(chainId) };
+// Route a chain-explicit command's Commands instance onto the resolved chain. Returns the
+// resolved chainId, or null (already logged) when resolution/switch fails so the action bails.
+async function routeExplicit(
+ commands: Commands,
+ flag?: string | number,
+): Promise {
+ try {
+ const target = resolveChainId(flag);
+ await commands.useChain(target);
+ return target;
+ } catch (e) {
+ console.error(chalk.red((e as Error).message));
+ return null;
+ }
}
export async function createCLI() {
@@ -285,6 +358,15 @@ export async function createCLI() {
console.error(chalk.red("Have you forgot to set env RPC?"));
process.exit(1);
}
+ // Validate the RPC shape (single URL or JSON map keyed by chainId) up front, so a
+ // malformed value fails fast with a clear, example-bearing message instead of deep
+ // inside the first command. Pure parse — no providers built, no network touched.
+ try {
+ parseRpcEnv(process.env.RPC);
+ } catch (e) {
+ console.error(chalk.red((e as Error).message));
+ process.exit(1);
+ }
}
// NODE_URL is optional: without it the CLI still starts, but only the commands in
@@ -518,14 +600,198 @@ export async function createCLI() {
// Best effort: a node that is down must not fail the command.
const status = await validateNode(current);
if (status) {
+ const nodeChains = nodeChainIds(status);
console.log(
- `Version: ${status.version}, chain(s): ${nodeChainIds(status).join(", ") || "none"}`,
+ `Version: ${status.version}, chain(s): ${nodeChains.join(", ") || "none"}`,
);
+ // Cross-reference the node's served chains with the RPC registry, surfacing any
+ // chain the node serves but for which no RPC is configured (commands would fail).
+ try {
+ loadRegistry();
+ const missing = nodeChains.filter((c) => !hasChain(Number(c)));
+ const configured = nodeChains.filter((c) => hasChain(Number(c)));
+ if (configured.length) {
+ console.log(
+ chalk.green(` RPC configured for chain(s): ${configured.join(", ")}`),
+ );
+ }
+ if (missing.length) {
+ console.log(
+ chalk.yellow(
+ ` No RPC configured for node chain(s): ${missing.join(
+ ", ",
+ )} — add one with 'addChain '.`,
+ ),
+ );
+ }
+ } catch {
+ // RPC not configured / unavailable — the node info above is still useful.
+ }
} else {
console.log(chalk.yellow("Node is not reachable right now."));
}
});
+ // ---------------------------------------------------------------------------
+ // Chain / RPC management (node-free — see NODE_FREE_COMMANDS). Implemented directly
+ // here, mirroring setNode/getNode: the registry is the single source of truth and no
+ // signer/Commands instance is needed. Errors are plain Error so the gate/REPL render
+ // them in red and stay alive.
+ // ---------------------------------------------------------------------------
+ const configuredChainList = (): string =>
+ listChains()
+ .map((c) => c.chainId)
+ .join(", ") || "none";
+
+ program
+ .command("addChain")
+ .alias("addRpc")
+ .description(
+ "Register an RPC chain at runtime (verifies each URL serves the chain; persists)",
+ )
+ .argument("", "Chain id the URL(s) serve")
+ .argument("[rpcUrl...]", "One or more RPC URLs for that chain")
+ .option("-c, --chainId ", "Chain id the URL(s) serve")
+ .option("-u, --url ", "One or more RPC URLs for that chain")
+ .action(async (chainIdArg, rpcUrlArgs, options) => {
+ loadRegistry();
+ const id = Number(options.chainId || chainIdArg);
+ const urls: string[] =
+ options.url && options.url.length ? options.url : rpcUrlArgs;
+ if (!Number.isInteger(id) || id <= 0) {
+ console.error(chalk.red(`Invalid chainId "${chainIdArg}".`));
+ return;
+ }
+ if (!urls || urls.length === 0) {
+ console.error(chalk.red("At least one RPC URL is required."));
+ return;
+ }
+ try {
+ await addChain(id, urls);
+ console.log(
+ chalk.green(
+ `Chain ${id} registered with ${urls.length} URL(s). Configured chains: ${configuredChainList()}.`,
+ ),
+ );
+ } catch (e) {
+ console.error(chalk.red((e as Error).message));
+ }
+ });
+
+ program
+ .command("removeChain")
+ .alias("removeRpc")
+ .description("Unregister an RPC chain (persists)")
+ .argument("", "Chain id to remove")
+ .option("-c, --chainId ", "Chain id to remove")
+ .action(async (chainIdArg, options) => {
+ loadRegistry();
+ const id = Number(options.chainId || chainIdArg);
+ if (!Number.isInteger(id) || id <= 0) {
+ console.error(chalk.red(`Invalid chainId "${chainIdArg}".`));
+ return;
+ }
+ try {
+ removeChain(id);
+ console.log(
+ chalk.green(
+ `Chain ${id} removed. Configured chains: ${configuredChainList()}.`,
+ ),
+ );
+ } catch (e) {
+ console.error(chalk.red((e as Error).message));
+ }
+ });
+
+ program
+ .command("listChains")
+ .alias("getRpcs")
+ .alias("chains")
+ .description("List configured RPC chains, their URLs, and the default")
+ .action(async () => {
+ loadRegistry();
+ // A legacy single-URL RPC isn't registered until its chainId is probed; do that
+ // (best effort) so it shows up here without needing to run a signing command first.
+ await getActiveChainId().catch(() => undefined);
+ const chains = listChains();
+ if (chains.length === 0) {
+ console.log(chalk.yellow("No RPC chains configured."));
+ return;
+ }
+ // Best-effort: cross-reference the node's served chains, if a node is set.
+ let nodeChains: number[] = [];
+ const current = getCurrentNodeUrl();
+ if (current) {
+ const status = await validateNode(current);
+ if (status) nodeChains = nodeChainIds(status).map((c) => Number(c));
+ }
+ const def = resolveDefaultChain(nodeChains);
+ console.log(chalk.bold("Configured RPC chains:"));
+ for (const { chainId, urls } of chains) {
+ const marks: string[] = [];
+ if (chainId === def) marks.push(chalk.green("default"));
+ if (nodeChains.includes(chainId)) marks.push("served by node");
+ const suffix = marks.length ? ` [${marks.join(", ")}]` : "";
+ console.log(` ${chainId}${suffix}`);
+ for (const u of urls) console.log(` ${u}`);
+ }
+ const nodeMissing = nodeChains.filter((c) => !hasChain(c));
+ if (nodeMissing.length) {
+ console.log(
+ chalk.yellow(
+ `Node serves chain(s) with no configured RPC: ${nodeMissing.join(", ")}.`,
+ ),
+ );
+ }
+ if (def === undefined) {
+ console.log(
+ chalk.yellow(
+ "No default chain set — chain-explicit commands need --chainId. Set one with 'setChain '.",
+ ),
+ );
+ }
+ });
+
+ program
+ .command("setChain")
+ .alias("useChain")
+ .description("Set the default (active) chain (must be registered; persists)")
+ .argument("", "Chain id to make default")
+ .option("-c, --chainId ", "Chain id to make default")
+ .action(async (chainIdArg, options) => {
+ loadRegistry();
+ const id = Number(options.chainId || chainIdArg);
+ if (!Number.isInteger(id) || id <= 0) {
+ console.error(chalk.red(`Invalid chainId "${chainIdArg}".`));
+ return;
+ }
+ try {
+ setDefaultChainId(id);
+ console.log(chalk.green(`Default chain is now ${id}.`));
+ } catch (e) {
+ console.error(chalk.red((e as Error).message));
+ }
+ });
+
+ program
+ .command("getChain")
+ .alias("currentChain")
+ .description("Show the current default (active) chain")
+ .action(async () => {
+ loadRegistry();
+ await getActiveChainId().catch(() => undefined);
+ const def = getDefaultChainId();
+ if (def !== undefined) {
+ console.log(`Default chain: ${def}`);
+ } else {
+ console.log(
+ chalk.yellow(
+ `No default chain set. Configured chains: ${configuredChainList()}. Set one with 'setChain '.`,
+ ),
+ );
+ }
+ });
+
// getDDO command
program
.command("getDDO")
@@ -704,6 +970,10 @@ export async function createCLI() {
"Auto-confirm payment for compute job (true/false)",
toBoolean,
)
+ .option(
+ "--chainId ",
+ "Payment/escrow chain for the job (defaults to the active chain). Each dataset/algorithm is still ordered on its own DDO chain.",
+ )
.option(
"-o, --output [output]",
"Output backend to save job results to. Supported types include S3, FTP, URL, Arweave, etc. Defaults to node local disk if omitted.",
@@ -777,6 +1047,17 @@ export async function createCLI() {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ // Payment/escrow chain (category d): `--chainId` → default → error. Independent
+ // of where the assets live — each asset is ordered on its own DDO chain inside
+ // the compute methods.
+ let paymentChainId: number;
+ try {
+ paymentChainId = resolveChainId(options.chainId);
+ } catch (e) {
+ console.error(chalk.red((e as Error).message));
+ return;
+ }
+
const initArgs = [
null,
dsDids,
@@ -790,7 +1071,10 @@ export async function createCLI() {
algoSvcId,
];
console.log("initArgs:", initArgs);
- const initResp = await commands.initializeCompute(initArgs);
+ const initResp = await commands.initializeCompute(
+ initArgs,
+ paymentChainId,
+ );
if (!initResp) {
console.error(chalk.red("Initialization failed. Aborting."));
@@ -799,8 +1083,11 @@ export async function createCLI() {
console.log(chalk.yellow("\n--- Payment Details ---"));
console.log(JSON.stringify(initResp, null, 2));
+ // The payment token lives on the payment chain, which may differ from the active
+ // chain — read its decimals with that chain's signer, not the default one.
+ const paymentSigner = await commands.signerFor(paymentChainId);
const amount = await unitsToAmount(
- signer,
+ paymentSigner,
initResp.payment.token,
initResp.payment.amount.toString(),
);
@@ -845,8 +1132,10 @@ export async function createCLI() {
algoSvcId,
];
- await commands.computeStart(computeArgs);
- console.log(chalk.green("Compute job started successfully."));
+ const started = await commands.computeStart(computeArgs, paymentChainId);
+ if (started) {
+ console.log(chalk.green("Compute job started successfully."));
+ }
},
);
@@ -896,6 +1185,10 @@ export async function createCLI() {
"-x, --algo-service [algoServiceId]",
"Algorithm Service ID (optional)",
)
+ .option(
+ "--chainId ",
+ "Chain to sign the free compute request on (defaults to the active chain). A free env does no ordering or payment.",
+ )
.action(
async (
datasetDids,
@@ -944,6 +1237,9 @@ export async function createCLI() {
}
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ // Free compute is single-chain signing only (no ordering/escrow): route the
+ // signer onto `--chainId` → default, exactly like a category (c) command.
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.freeComputeStart([
null,
dsDids,
@@ -1147,6 +1443,10 @@ export async function createCLI() {
"Max seconds to wait for Running (default 600)",
parseInt,
)
+ .option(
+ "--chainId ",
+ "Payment/escrow chain (default: active chain); must be one the env prices on",
+ )
.action(async (computeEnvId, duration, paymentToken, options) => {
const envId = options.env || computeEnvId;
const token = paymentToken;
@@ -1186,6 +1486,9 @@ export async function createCLI() {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ // Services are single-chain: --chainId (→ default) is the payment/escrow chain, and
+ // must be registered here; startService additionally checks it is one the env prices on.
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.startService({
envId,
duration,
@@ -1333,6 +1636,7 @@ export async function createCLI() {
"Auto-confirm payment (true/false)",
toBoolean,
)
+ .option("--chainId ", "Payment/escrow chain (default: active chain)")
.action(async (serviceId, additionalDuration, paymentToken, options) => {
const id = options.service || serviceId;
const addl = options.duration || additionalDuration;
@@ -1355,6 +1659,7 @@ export async function createCLI() {
}
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.extendService(id, addl, token, options.accept);
});
@@ -1497,10 +1802,16 @@ export async function createCLI() {
program
.command("mintOcean")
.description("Mints Ocean tokens")
- .action(async () => {
+ .option(
+ "-t, --token ",
+ "Ocean token address (overrides the chain's configured address; required on chains with no bundled Ocean token)",
+ )
+ .option("--chainId ", "Chain to mint on (default: active chain)")
+ .action(async (options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
- await commands.mintOceanTokens();
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
+ await commands.mintOceanTokens(options.token);
});
// Generate new auth token
@@ -1533,16 +1844,19 @@ export async function createCLI() {
.argument("", "Amount of tokens to deposit")
.option("-t, --token ", "Address of the token to deposit")
.option("-a, --amount ", "Amount of tokens to deposit")
+ .option("--chainId ", "Escrow chain (default: active chain)")
.action(async (token, amount, options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ const target = await routeExplicit(commands, options.chainId);
+ if (target === null) return;
const tokenAddress = options.token || token;
const amountToDeposit = options.amount || amount;
const success = await commands.depositToEscrow(
- signer,
+ commands.signer,
tokenAddress,
amountToDeposit,
- chainId,
+ target,
);
if (!success) {
console.log(chalk.red("Deposit failed"));
@@ -1558,9 +1872,11 @@ export async function createCLI() {
.description("Get deposited token amount in escrow for user")
.argument("", "Address of the token to check")
.option("-t, --token ", "Address of the token to check")
+ .option("--chainId ", "Escrow chain (default: active chain)")
.action(async (token, options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.getEscrowBalance(token || options.token);
});
@@ -1572,9 +1888,11 @@ export async function createCLI() {
.argument("", "Amount of tokens to withdraw")
.option("-t, --token ", "Address of the token to check")
.option("-a, --amount ", "Amount of tokens to withdraw")
+ .option("--chainId ", "Escrow chain (default: active chain)")
.action(async (token, amount, options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.withdrawFromEscrow(token || options.token, amount);
});
@@ -1601,6 +1919,7 @@ export async function createCLI() {
"-c, --maxLockCounts ",
"Maximum number of locks allowed",
)
+ .option("--chainId ", "Escrow chain (default: active chain)")
.action(
async (
token,
@@ -1612,6 +1931,7 @@ export async function createCLI() {
) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
const tokenAddress = options.token || token;
const payeeAddress = options.payee || payee;
const maxLockedAmountValue = options.maxLockedAmount || maxLockedAmount;
@@ -1642,9 +1962,11 @@ export async function createCLI() {
.argument("", "Address of the payee to check")
.option("-t, --token ", "Address of the token to check")
.option("-p, --payee ", "Address of the payee to check")
+ .option("--chainId ", "Escrow chain (default: active chain)")
.action(async (token, payee, options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.getAuthorizationsEscrow(
token || options.token,
payee || options.payee,
@@ -1678,9 +2000,11 @@ export async function createCLI() {
"Whether tokens are transferable (true/false)",
"false",
)
+ .option("--chainId ", "Chain to deploy on (default: active chain)")
.action(async (name, symbol, initialUsers, transferable, options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.createAccessList([
options.name || name,
options.symbol || symbol,
@@ -1702,9 +2026,11 @@ export async function createCLI() {
"-u, --users ",
"Comma-separated list of user addresses to add",
)
+ .option("--chainId ", "Access-list chain (default: active chain)")
.action(async (accessListAddress, users, options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.addToAccessList([
options.address || accessListAddress,
options.users || users,
@@ -1724,9 +2050,11 @@ export async function createCLI() {
"-u, --users ",
"Comma-separated list of user addresses to check",
)
+ .option("--chainId ", "Access-list chain (default: active chain)")
.action(async (accessListAddress, users, options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.checkAccessList([
options.address || accessListAddress,
options.users || users,
@@ -1746,9 +2074,11 @@ export async function createCLI() {
"-u, --users ",
"Comma-separated list of user addresses to remove",
)
+ .option("--chainId ", "Access-list chain (default: active chain)")
.action(async (accessListAddress, users, options) => {
const { signer, chainId } = await initializeSigner();
const commands = new Commands(signer, chainId);
+ if ((await routeExplicit(commands, options.chainId)) === null) return;
await commands.removeFromAccessList([
options.address || accessListAddress,
options.users || users,
diff --git a/src/commands.ts b/src/commands.ts
index 68934cd..83bee58 100644
--- a/src/commands.ts
+++ b/src/commands.ts
@@ -9,10 +9,19 @@ import {
getIndexingWaitSettings,
IndexerWaitParams,
fixAndParseProviderFees,
- getConfigByChainId,
resolveComputeInputs,
isOrderable,
+ computeJobChainIds,
+ summarizeComputeEnvFees,
+ getDdoChainId,
} from "./helpers.js";
+import {
+ getConfigFor,
+ getSigner,
+ requireAddress,
+ hasChain,
+ listChains,
+} from "./rpcRegistry.js";
import {
Aquarius,
ComputeAsset,
@@ -21,6 +30,7 @@ import {
ConfigHelper,
Datatoken,
ProviderInstance,
+ ProviderInitialize,
amountToUnits,
getHash,
orderAsset,
@@ -49,6 +59,7 @@ import chalk from "chalk";
import {
getPolicyServerOBJ,
getPolicyServerOBJs,
+ isPolicyServerConfigured,
isVersionGte,
} from "./policyServerHelper.js";
import {
@@ -120,6 +131,16 @@ export class Commands {
constructor(signer: Signer, network: string | number, config?: Config) {
this.signer = signer;
this.config = config || new ConfigHelper().getConfig(network);
+ if (!this.config) {
+ // No bundled ocean.js config for the active chain and no ADDRESS_FILE entry.
+ // Fail clearly here rather than crashing on the `this.config.nodeUri` write below.
+ throw new Error(
+ `Chain ${network} has no ocean.js contract config (unknown to ConfigHelper ` +
+ `and absent from ADDRESS_FILE). Point ADDRESS_FILE at a deployment for this ` +
+ `chain, set a supported default chain with 'setChain ', or use a ` +
+ `chain ocean.js supports.`,
+ );
+ }
this.oceanNodeUrl = process.env.NODE_URL;
this.indexingParams = getIndexingWaitSettings();
console.log("Using Ocean Node URL :", this.oceanNodeUrl);
@@ -127,6 +148,143 @@ export class Commands {
this.aquarius = new Aquarius(this.oceanNodeUrl);
}
+ // ---------------------------------------------------------------------------
+ // Chain parameterization. The constructor pins a *default* chain (so commands that
+ // don't opt in are unchanged); routed commands re-point the instance at the chain the
+ // request actually targets. A fresh Commands instance is built per CLI invocation and
+ // methods run one at a time, so mutating this.signer/this.config here is safe.
+ // Phase 3 (multi-chain compute) uses configFor/signerFor directly, per asset, instead.
+ // ---------------------------------------------------------------------------
+ public configFor(chainId: number): Config {
+ const cfg = getConfigFor(chainId);
+ if (!cfg) {
+ // ocean.js ConfigHelper has no bundled config for this chain and no
+ // ADDRESS_FILE entry supplies one. Fail with a clear, actionable message
+ // instead of letting a null config crash deep in a later `.chainId` read.
+ throw new Error(
+ `Chain ${chainId} has a registered RPC but no ocean.js contract config ` +
+ `(unknown to ConfigHelper and absent from ADDRESS_FILE). Point ADDRESS_FILE ` +
+ `at a deployment for this chain, or use a chain ocean.js supports.`,
+ );
+ }
+ cfg.nodeUri = this.oceanNodeUrl;
+ return cfg;
+ }
+
+ public async signerFor(chainId: number): Promise {
+ return getSigner(chainId);
+ }
+
+ // Re-point this instance at `chainId`: its signer and config become that chain's.
+ // Used by category (b) (chain implied by the asset's DDO) and category (c) (explicit
+ // --chainId) commands.
+ public async useChain(chainId: number): Promise {
+ this.signer = await this.signerFor(chainId);
+ this.config = this.configFor(chainId);
+ }
+
+ // A DDO's chainId lives at the top level in 4.1.0 DDOs but under
+ // `credentialSubject.chainId` in v5 DDOs. Delegates to the shared `getDdoChainId`
+ // helper so routing and compute ordering read the chain the same way.
+ private ddoChainId(ddo: unknown): unknown {
+ return getDdoChainId(ddo);
+ }
+
+ // Category (b): re-point at the chain the asset lives on (its DDO's chainId). Returns
+ // false (already logged) if the chainId is missing/invalid or not configured, so the
+ // caller can bail. Also fixes the old bug where publish ignored the DDO's chainId.
+ private async routeToAssetChain(
+ rawChainId: unknown,
+ label: string,
+ ): Promise {
+ const cid = Number(rawChainId);
+ if (!Number.isInteger(cid) || cid <= 0) {
+ console.error(
+ chalk.red(
+ `${label} has no valid chainId (got ${JSON.stringify(
+ rawChainId,
+ )}); cannot determine which chain to use.`,
+ ),
+ );
+ return false;
+ }
+ try {
+ await this.useChain(cid);
+ } catch (e) {
+ console.error(chalk.red((e as Error).message));
+ return false;
+ }
+ return true;
+ }
+
+ // Category (d) — multi-chain compute. A single compute job may order datasets and the
+ // algorithm on different chains from each other while paying/escrowing on yet another
+ // (see `computeJobChainIds`). Every one of those chains needs a registered RPC so it
+ // has a signer/config for its own ordering (or the escrow payment). Validate up front,
+ // before any paid order is placed, and error listing the missing chain(s) — the same
+ // shape as the category (c) "chain not configured" errors. Returns false (already
+ // logged) so the caller bails. Raw fileObject assets (null DDO) add no chain.
+ private ensureComputeChainsRegistered(
+ paymentChainId: number,
+ ddos: (Asset | null | undefined)[],
+ algoDdo: Asset | null,
+ ): boolean {
+ let needed: number[];
+ try {
+ needed = computeJobChainIds(paymentChainId, ddos, algoDdo);
+ } catch (e) {
+ // A malformed DDO (non-null but no resolvable chainId) — fail clearly here rather
+ // than crashing later as orderCtxFor(NaN) mid-ordering.
+ console.error(chalk.red((e as Error).message));
+ return false;
+ }
+ const missing = needed.filter((c) => !hasChain(c));
+ if (missing.length > 0) {
+ console.error(
+ chalk.red(
+ `Compute job needs RPC chain(s) ${missing.join(", ")} but they are not ` +
+ `configured. Add each with 'addChain '. Configured ` +
+ `chains: ${
+ listChains()
+ .map((c) => c.chainId)
+ .join(", ") || "none"
+ }.`,
+ ),
+ );
+ return false;
+ }
+ // Also require an ocean.js contract config for every chain up front — a registered
+ // RPC alone is not enough to order/escrow on it. Checking here, before any order is
+ // placed, avoids crashing mid-job (e.g. after the algorithm was already ordered and
+ // paid) when a later asset's chain has no config.
+ const unconfigured = needed.filter((c) => !getConfigFor(c));
+ if (unconfigured.length > 0) {
+ console.error(
+ chalk.red(
+ `Compute job needs ocean.js contract config for chain(s) ` +
+ `${unconfigured.join(", ")}, but ConfigHelper has none and ADDRESS_FILE ` +
+ `supplies none. Point ADDRESS_FILE at a deployment for these chains, or use ` +
+ `chains ocean.js supports.`,
+ ),
+ );
+ return false;
+ }
+ return true;
+ }
+
+ // Place an order (via `handleComputeOrder`) with a bounded retry. On a fast chain the
+ // first order after prior transactions can hit a transient stale/lagged nonce — the
+ // rejected send places no order and returns a falsy tx id, so retrying after a short
+ // delay (which lets the node's pending nonce catch up) is safe and never double-orders.
+ private async orderWithRetry(place: () => Promise): Promise {
+ let result = await place();
+ for (let attempt = 1; attempt < 3 && !result; attempt++) {
+ await this.sleep(2000);
+ result = await place();
+ }
+ return result;
+ }
+
public async start() {
console.log("Starting the interactive CLI flow...\n\n");
const data = await interactiveFlow(this.oceanNodeUrl); // Collect data via CLI
@@ -152,6 +310,12 @@ export class Commands {
return;
}
const encryptDDO = args[2] === "false" ? false : true;
+ // The chain is the one the DDO declares — publish on that chain, not the RPC's
+ // default. (Previously the DDO's chainId was ignored.)
+ if (
+ !(await this.routeToAssetChain(this.ddoChainId(asset), "Metadata file"))
+ )
+ return;
try {
const ddoInstance = DDOManager.getDDOClass(asset);
const { indexedMetadata } = ddoInstance.getAssetFields();
@@ -186,6 +350,13 @@ export class Commands {
return;
}
const encryptDDO = args[2] === "false" ? false : true;
+ if (
+ !(await this.routeToAssetChain(
+ this.ddoChainId(algoAsset),
+ "Metadata file",
+ ))
+ )
+ return;
// add some more checks
try {
const ddoInstance = DDOManager.getDDOClass(algoAsset);
@@ -241,6 +412,8 @@ export class Commands {
asset[key] = updateJson[key];
}
+ if (!(await this.routeToAssetChain(this.ddoChainId(asset), "DDO"))) return;
+
const updateAssetTx = await updateAssetMetadata(
this.signer,
asset,
@@ -269,6 +442,57 @@ export class Commands {
} else console.log(util.inspect(resolvedDDO, false, null, true));
}
+ private async initializeProvider(
+ asset: Asset,
+ serviceId: string,
+ accountId: string,
+ providerUrl: string,
+ ): Promise {
+ // Only run SSI/policy-server verification when a wallet is configured AND
+ // the node confirms it has a policy server. This mirrors getPolicyServerOBJ's
+ // skip behavior, so a download against a node without a policy server
+ // proceeds instead of failing in initializePSVerification.
+ if (
+ process.env.SSI_WALLET_API?.trim() &&
+ (await isPolicyServerConfigured(providerUrl))
+ ) {
+ const command = {
+ documentId: asset.id,
+ serviceId,
+ consumerAddress: accountId,
+ policyServer: {
+ sessionId: "",
+ successRedirectUri: "",
+ errorRedirectUri: "",
+ responseRedirectUri: "",
+ presentationDefinitionUri: "",
+ },
+ };
+ const initializePs = await ProviderInstance.initializePSVerification(
+ providerUrl,
+ this.signer,
+ command,
+ );
+ if (!initializePs?.success) {
+ throw new Error(
+ `Provider initialization failed: ${initializePs?.error || "Policy Server verification failed"}`,
+ );
+ }
+ }
+ try {
+ return await ProviderInstance.initialize(
+ asset.id,
+ serviceId,
+ 0,
+ accountId,
+ providerUrl,
+ );
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ throw new Error(message.replace(/^Error:\s*/i, ""), { cause: error });
+ }
+ }
+
public async download(args: string[]) {
const did = args[1];
const dataDdo = await this.aquarius.waitForIndexer(
@@ -283,23 +507,46 @@ export class Commands {
return;
}
+ if (!(await this.routeToAssetChain(this.ddoChainId(dataDdo), "DDO")))
+ return;
+
const ddoInstance = DDOManager.getDDOClass(dataDdo);
const { services, version } = ddoInstance.getDDOFields();
const serviceId = args[3] ? args[3] : services[0].id;
+ const service = services.find((s) => s.id === serviceId);
+ if (!service) {
+ console.error(
+ chalk.red(`Service ID "${serviceId}" not found in DDO ${did}.`),
+ );
+ return;
+ }
+
let policyServer = null;
- try {
- if (isVersionGte(version, "5.0.0")) {
+ if (isVersionGte(version, "5.0.0")) {
+ try {
+ await this.initializeProvider(
+ dataDdo,
+ serviceId,
+ await this.signer.getAddress(),
+ service.serviceEndpoint || this.oceanNodeUrl,
+ );
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(chalk.red("Error initializing Provider:"), message);
+ return;
+ }
+ try {
policyServer = await getPolicyServerOBJ(
dataDdo,
serviceId,
this.signer,
this.oceanNodeUrl,
);
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(chalk.red("Error getting Policy Server Object:"), message);
+ return;
}
- } catch (error) {
- throw new Error("Error getting Policy Server Object: " + error.message, {
- cause: error,
- });
}
const datatoken = new Datatoken(
this.signer,
@@ -308,19 +555,28 @@ export class Commands {
);
// Order the same service that policy retrieval and getDownloadUrl target.
const serviceIndex = services.findIndex((s) => s.id === serviceId);
- const tx = await orderAsset(
- dataDdo,
- this.signer,
- this.config,
- datatoken,
- this.oceanNodeUrl,
- undefined, // consumerAddress
- undefined, // consumeMarketOrderFee
- undefined, // providerFees
- undefined, // consumeMarketFixedSwapFee
- undefined, // datatokenIndex
- serviceIndex < 0 ? 0 : serviceIndex,
- );
+ let tx;
+ try {
+ tx = await this.orderWithRetry(() =>
+ orderAsset(
+ dataDdo,
+ this.signer,
+ this.config,
+ datatoken,
+ this.oceanNodeUrl,
+ undefined, // consumerAddress
+ undefined, // consumeMarketOrderFee
+ undefined, // providerFees
+ undefined, // consumeMarketFixedSwapFee
+ undefined, // datatokenIndex
+ serviceIndex < 0 ? 0 : serviceIndex,
+ ),
+ );
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(chalk.red("Error ordering asset:"), message);
+ return;
+ }
if (!tx) {
console.error(
@@ -356,7 +612,7 @@ export class Commands {
}
}
- public async initializeCompute(args: string[]) {
+ public async initializeCompute(args: string[], paymentChainId?: number) {
const resolved = await resolveComputeInputs(
args[1],
args[2],
@@ -368,6 +624,23 @@ export class Commands {
const { assets, algo, ddos, algoDdo } = resolved;
let { providerURI } = resolved;
+ // The payment/escrow chain (category d): the `--chainId` the caller resolved, else
+ // the signer's own chain (single-chain back-compat). Independent of where the assets
+ // live — each asset is ordered on its own DDO chain further below.
+ const payChain =
+ paymentChainId ??
+ Number((await this.signer.provider.getNetwork()).chainId);
+ // Every chain the job touches (payment + each DID asset/algo chain) must have an RPC.
+ if (
+ !this.ensureComputeChainsRegistered(
+ payChain,
+ ddos as (Asset | null)[],
+ (algoDdo as Asset) ?? null,
+ )
+ )
+ return;
+ const paymentSigner = await this.signerFor(payChain);
+
// Optional per-dataset service selection (positional, 1-1 with datasets).
const inputServicesString = args[8];
let inputServices: string[] = [];
@@ -547,14 +820,18 @@ export class Commands {
);
return;
}
- const { chainId } = await this.signer.provider.getNetwork();
+ // The payment chain must be advertised by the compute env (in `computeEnv.fees`) AND
+ // registered in the RPC registry (already checked above via ensureComputeChainsRegistered).
+ const chainId = payChain;
if (!Object.keys(computeEnv.fees).includes(chainId.toString())) {
console.error(
"Error starting paid compute using dataset DID " +
args[1] +
" and algorithm DID " +
args[2] +
- " because chainId is not supported by compute environment. " +
+ " because the payment chain " +
+ chainId +
+ " is not supported by compute environment " +
args[3] +
". Supported chain IDs: " +
Object.keys(computeEnv.fees).join(", "),
@@ -593,7 +870,7 @@ export class Commands {
const policiesServer = await getPolicyServerOBJs(
assetsForPolicy,
assetAlgo,
- this.signer,
+ paymentSigner,
this.oceanNodeUrl,
);
const parsedResources = JSON.parse(resources);
@@ -605,7 +882,7 @@ export class Commands {
paymentToken,
supportedMaxJobDuration,
providerURI,
- await this.signer.getAddress(),
+ await paymentSigner.getAddress(),
parsedResources,
Number(chainId),
policiesServer,
@@ -627,7 +904,7 @@ export class Commands {
return providerInitializeComputeJob;
}
- public async computeStart(args: string[]) {
+ public async computeStart(args: string[], paymentChainId?: number) {
const resolved = await resolveComputeInputs(
args[1],
args[2],
@@ -639,6 +916,47 @@ export class Commands {
const { assets, algo, ddos, algoDdo } = resolved;
let { providerURI } = resolved;
+ // Payment/escrow chain (category d): the caller-resolved `--chainId`, else the
+ // signer's own chain (single-chain back-compat). Assets are ordered on their own
+ // DDO chains below; payment/escrow/computeStart all run against this chain.
+ const payChain =
+ paymentChainId ??
+ Number((await this.signer.provider.getNetwork()).chainId);
+ if (
+ !this.ensureComputeChainsRegistered(
+ payChain,
+ ddos as (Asset | null)[],
+ (algoDdo as Asset) ?? null,
+ )
+ )
+ return;
+ const paymentSigner = await this.signerFor(payChain);
+
+ // Per-asset ordering context: each DID-based asset is ordered on ITS OWN chain, with
+ // that chain's signer + config + Datatoken. A job may mix asset chains and pay on a
+ // different one; the old single Datatoken on the signer's one chain could only order
+ // same-chain assets. In the common single-chain case every asset shares the payment
+ // chain, so this is equivalent to before. Memoized per chain within this call.
+ const orderCtxCache = new Map<
+ number,
+ { signer: Signer; config: Config; datatoken: Datatoken }
+ >();
+ const orderCtxFor = async (
+ chainId: number,
+ ): Promise<{ signer: Signer; config: Config; datatoken: Datatoken }> => {
+ const cached = orderCtxCache.get(chainId);
+ if (cached) return cached;
+ const s = await this.signerFor(chainId);
+ const c = this.configFor(chainId);
+ const ctx = {
+ signer: s,
+ config: c,
+ datatoken: new Datatoken(s, String(chainId), c),
+ };
+ orderCtxCache.set(chainId, ctx);
+ return ctx;
+ };
+
// Optional per-dataset service selection (positional, 1-1 with datasets).
const inputServicesString = args[9];
let inputServices: string[] = [];
@@ -813,7 +1131,7 @@ export class Commands {
const policiesServer = await getPolicyServerOBJs(
assetsForPolicy,
assetAlgo,
- this.signer,
+ paymentSigner,
this.oceanNodeUrl,
);
@@ -821,24 +1139,22 @@ export class Commands {
const parsedProviderInitializeComputeJob = fixAndParseProviderFees(
providerInitializeComputeJob,
);
- const datatoken = new Datatoken(
- this.signer,
- (await this.signer.provider.getNetwork()).chainId.toString(),
- this.config,
- );
// Only order DID-based algorithms; raw (fileObject) algorithms have no datatoken.
if (algoDdo) {
+ const algoCtx = await orderCtxFor(Number(getDdoChainId(algoDdo)));
console.log("Ordering algorithm: ", args[2]);
- algo.transferTxId = await handleComputeOrder(
- parsedProviderInitializeComputeJob?.algorithm,
- algoDdo as Asset,
- this.signer,
- computeEnv.consumerAddress,
- algoServiceIndex,
- datatoken,
- this.config,
- parsedProviderInitializeComputeJob?.algorithm?.providerFee,
- providerURI,
+ algo.transferTxId = await this.orderWithRetry(() =>
+ handleComputeOrder(
+ parsedProviderInitializeComputeJob?.algorithm,
+ algoDdo as Asset,
+ algoCtx.signer,
+ computeEnv.consumerAddress,
+ algoServiceIndex,
+ algoCtx.datatoken,
+ algoCtx.config,
+ parsedProviderInitializeComputeJob?.algorithm?.providerFee,
+ providerURI,
+ ),
);
if (!algo.transferTxId) {
console.error(
@@ -858,16 +1174,19 @@ export class Commands {
if (!dataDdo) continue;
const feeEntry = parsedProviderInitializeComputeJob?.datasets?.[i];
if (!feeEntry) continue;
- assets[i].transferTxId = await handleComputeOrder(
- feeEntry,
- dataDdo as Asset,
- this.signer,
- computeEnv.consumerAddress,
- datasetServiceIndex[i] ?? 0,
- datatoken,
- this.config,
- feeEntry.providerFee,
- providerURI,
+ const dsCtx = await orderCtxFor(Number(getDdoChainId(dataDdo)));
+ assets[i].transferTxId = await this.orderWithRetry(() =>
+ handleComputeOrder(
+ feeEntry,
+ dataDdo as Asset,
+ dsCtx.signer,
+ computeEnv.consumerAddress,
+ datasetServiceIndex[i] ?? 0,
+ dsCtx.datatoken,
+ dsCtx.config,
+ feeEntry.providerFee,
+ providerURI,
+ ),
);
if (!assets[i].transferTxId) {
console.error(
@@ -904,7 +1223,9 @@ export class Commands {
if (maxJobDuration > computeEnv.maxJobDuration) {
supportedMaxJobDuration = computeEnv.maxJobDuration;
}
- const { chainId } = await this.signer.provider.getNetwork();
+ // Payment chain must be advertised by the env AND registered (registry already
+ // validated at method entry via ensureComputeChainsRegistered).
+ const chainId = payChain;
const paymentToken = args[6];
if (!paymentToken) {
console.error(
@@ -922,7 +1243,9 @@ export class Commands {
args[1] +
" and algorithm DID " +
args[2] +
- " because chainId is not supported by compute environment. " +
+ " because the payment chain " +
+ chainId +
+ " is not supported by compute environment " +
args[3] +
". Supported chain IDs: " +
Object.keys(computeEnv.fees).join(", "),
@@ -961,7 +1284,7 @@ export class Commands {
const escrow = new EscrowContract(
getAddress(parsedProviderInitializeComputeJob.payment.escrowAddress),
- this.signer,
+ paymentSigner,
);
console.log("Verifying payment...");
await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -970,7 +1293,7 @@ export class Commands {
paymentToken,
computeEnv.consumerAddress,
await unitsToAmount(
- this.signer,
+ paymentSigner,
paymentToken,
parsedProviderInitializeComputeJob.payment.amount,
),
@@ -997,7 +1320,7 @@ export class Commands {
// still reports isValid. The node then rejects computeStart with "User ... does
// not have enough funds" or "Found 0 authorizations". Confirm both really
// landed, and retry once each before giving up.
- const payerAddress = await this.signer.getAddress();
+ const payerAddress = await paymentSigner.getAddress();
const payeeAddress = getAddress(computeEnv.consumerAddress);
const tokenAddress = getAddress(paymentToken);
const minLockSeconds =
@@ -1015,7 +1338,7 @@ export class Commands {
if (available < requiredUnits) {
const shortfallUnits = requiredUnits - available;
const shortfall = await unitsToAmount(
- this.signer,
+ paymentSigner,
paymentToken,
shortfallUnits.toString(),
);
@@ -1027,7 +1350,7 @@ export class Commands {
const tokenContract = new ethers.Contract(
paymentToken,
["function approve(address spender, uint256 amount) returns (bool)"],
- this.signer,
+ paymentSigner,
);
const approveTx = await tokenContract.approve(
getAddress(parsedProviderInitializeComputeJob.payment.escrowAddress),
@@ -1040,7 +1363,7 @@ export class Commands {
}
if (available < requiredUnits) {
const needed = await unitsToAmount(
- this.signer,
+ paymentSigner,
paymentToken,
requiredUnits.toString(),
);
@@ -1068,7 +1391,7 @@ export class Commands {
// maxLockedAmount until they are claimed, so a ceiling of exactly one job's
// cost would reject the next job started before this one settles.
const jobCost = await unitsToAmount(
- this.signer,
+ paymentSigner,
paymentToken,
parsedProviderInitializeComputeJob.payment.amount,
);
@@ -1131,14 +1454,14 @@ export class Commands {
}
const computeJobs = await ProviderInstance.computeStart(
providerURI,
- this.signer,
+ paymentSigner,
computeEnv.id,
assets, // assets[0] // only c2d v1,
algo,
supportedMaxJobDuration,
paymentToken,
JSON.parse(resources),
- Number((await this.signer.provider.getNetwork()).chainId),
+ Number(payChain),
null,
null,
// additionalDatasets, only c2d v1
@@ -1152,9 +1475,10 @@ export class Commands {
const { jobId, payment } = computeJobs[0];
console.log("Compute started. JobID: " + jobId);
console.log("Agreement ID: " + payment.lockTx);
- } else {
- console.log("Error while starting the compute job: ", computeJobs);
+ return true;
}
+ console.log("Error while starting the compute job: ", computeJobs);
+ return false;
}
public async freeComputeStart(args: string[]) {
@@ -1384,6 +1708,14 @@ export class Commands {
return;
}
+ // Readable per-env summary of where each env accepts payment (fee chains + tokens),
+ // so a user can pick `--chainId` / `--paymentToken` for startCompute without reading
+ // the raw JSON below.
+ console.log(chalk.yellow("--- Payment options per environment ---"));
+ for (const env of computeEnvs) {
+ console.log(summarizeComputeEnvFees(env));
+ }
+
console.log("Existing compute environments: ", JSON.stringify(computeEnvs));
}
@@ -2401,6 +2733,8 @@ export class Commands {
);
return;
}
+ // Route to the dataset's chain before the owner check / metadata update.
+ if (!(await this.routeToAssetChain(this.ddoChainId(asset), "DDO"))) return;
const ddoInstance = DDOManager.getDDOClass(asset);
const { indexedMetadata } = ddoInstance.getAssetFields();
const { services } = ddoInstance.getDDOFields();
@@ -2491,6 +2825,8 @@ export class Commands {
);
return;
}
+ // Route to the dataset's chain before the owner check / metadata update.
+ if (!(await this.routeToAssetChain(this.ddoChainId(asset), "DDO"))) return;
const ddoInstance = DDOManager.getDDOClass(asset);
const { indexedMetadata } = ddoInstance.getAssetFields();
const { services } = ddoInstance.getDDOFields();
@@ -2603,9 +2939,22 @@ export class Commands {
}
}
- public async mintOceanTokens() {
+ public async mintOceanTokens(tokenOverride?: string) {
try {
- const config = await getConfigByChainId(Number(this.config.chainId));
+ const chainId = Number(this.config.chainId);
+ // Token resolution: --token flag → chain's configured oceanTokenAddress → bail.
+ // oceanTokenAddress is absent for some chains (e.g. Base), so a clear error
+ // beats failing deep inside an ethers call.
+ const tokenAddress =
+ tokenOverride || getConfigFor(chainId)?.oceanTokenAddress;
+ if (!tokenAddress) {
+ console.error(
+ chalk.red(
+ `No Ocean token address configured for chain ${chainId}. Pass --token to mint on this chain.`,
+ ),
+ );
+ return;
+ }
const minAbi = [
{
constant: false,
@@ -2622,7 +2971,7 @@ export class Commands {
];
const tokenContract = new ethers.Contract(
- config?.Ocean,
+ tokenAddress,
minAbi,
this.signer,
);
@@ -2668,11 +3017,11 @@ export class Commands {
}
public async getEscrowBalance(token: string): Promise {
- const config = await getConfigByChainId(Number(this.config.chainId));
+ const chainId = Number(this.config.chainId);
const escrow = new EscrowContract(
- getAddress(config.Escrow),
+ getAddress(requireAddress(chainId, "escrow", "Escrow")),
this.signer,
- Number(this.config.chainId),
+ chainId,
);
try {
@@ -2699,11 +3048,11 @@ export class Commands {
token: string,
amount: string,
): Promise {
- const config = await getConfigByChainId(Number(this.config.chainId));
+ const chainId = Number(this.config.chainId);
const escrow = new EscrowContract(
- getAddress(config.Escrow),
+ getAddress(requireAddress(chainId, "escrow", "Escrow")),
this.signer,
- Number(this.config.chainId),
+ chainId,
);
const balance = await this.getEscrowBalance(token);
@@ -2725,8 +3074,7 @@ export class Commands {
) {
try {
const amountInUnits = await amountToUnits(signer, token, amount, 18);
- const config = await getConfigByChainId(chainId);
- const escrowAddress = config.Escrow;
+ const escrowAddress = requireAddress(chainId, "escrow", "Escrow");
const tokenContract = new ethers.Contract(
token,
@@ -2783,8 +3131,11 @@ export class Commands {
}
}
- const config = await getConfigByChainId(Number(this.config.chainId));
- const escrowAddress = config.Escrow;
+ const escrowAddress = requireAddress(
+ Number(this.config.chainId),
+ "escrow",
+ "Escrow",
+ );
const escrow = new EscrowContract(getAddress(escrowAddress), this.signer);
@@ -2833,16 +3184,16 @@ export class Commands {
}
public async getAuthorizationsEscrow(token: string, payee: string) {
- const config = await getConfigByChainId(Number(this.config.chainId));
+ const chainId = Number(this.config.chainId);
const payer = await this.signer.getAddress();
const tokenAddress = getAddress(token);
const payerAddress = getAddress(payer);
const payeeAddress = getAddress(payee);
const decimals = await getTokenDecimals(this.signer, token);
const escrow = new EscrowContract(
- getAddress(config.Escrow),
+ getAddress(requireAddress(chainId, "escrow", "Escrow")),
this.signer,
- Number(this.config.chainId),
+ chainId,
);
const authorizations = await escrow.getAuthorizations(
@@ -2893,19 +3244,20 @@ export class Commands {
return;
}
- const config = await getConfigByChainId(Number(this.config.chainId));
- if (!config.AccessListFactory) {
+ const chainId = Number(this.config.chainId);
+ const config = getConfigFor(chainId);
+ if (!config?.accessListFactory) {
console.error(
chalk.red(
- "Access list factory not found. Check local address.json file",
+ `Access list factory address not found for chain ${chainId}. Set ADDRESS_FILE to a deployment for this chain, or use a supported chain.`,
),
);
return;
}
const accessListFactory = new AccesslistFactory(
- config.AccessListFactory,
+ config.accessListFactory,
this.signer,
- Number(this.config.chainId),
+ chainId,
);
const owner = await this.signer.getAddress();
@@ -3093,11 +3445,16 @@ export class Commands {
// on a send failure — ocean.js's sendPreparedTransaction swallows
// the error. The common one here is a nonce collision between
// back-to-back burns on a fast local chain: the rejected tx leaves
- // the account nonce advanced, so simply rebuilding the tx (a fresh
+ // the account nonce advanced, so rebuilding the tx (a fresh
// populateTransaction picks up the corrected nonce) succeeds. Retry
- // a null result a few times before giving up.
+ // a null result a few times before giving up — but WAIT between
+ // attempts: ethers caches getTransactionCount("pending") for
+ // ~cacheTimeout (250ms default), so an immediate retry re-reads the
+ // same stale nonce and fails again. A short delay lets that cache
+ // expire so the retry sees the advanced nonce.
let receipt = null;
- for (let attempt = 0; attempt < 3 && !receipt; attempt++) {
+ for (let attempt = 0; attempt < 5 && !receipt; attempt++) {
+ if (attempt > 0) await this.sleep(1000);
receipt = await accessList.burn(tokenId);
}
if (!receipt) {
diff --git a/src/helpers.ts b/src/helpers.ts
index 8b69676..2054c5d 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -24,7 +24,6 @@ import {
createAsset,
LoggerInstance,
} from "@oceanprotocol/lib";
-import { homedir } from "os";
import { createRequire } from "module";
// Resolve the ERC20 template ABI through the module system rather than a
@@ -626,20 +625,85 @@ export function toBoolean(value) {
return Boolean(value);
}
-export async function getConfigByChainId(chainId: number) {
- const addressFilePath =
- process.env.ADDRESS_FILE ||
- `${homedir}/.ocean/ocean-contracts/artifacts/address.json`;
- const addressFile = await fs.readFile(addressFilePath, "utf8");
+// ---------------------------------------------------------------------------
+// Multi-chain compute helpers (Phase 3).
+// ---------------------------------------------------------------------------
+
+// A DDO's chainId lives at the top level in 4.1.0 DDOs but under
+// `credentialSubject.chainId` in v5 DDOs — read whichever is present so both metadata
+// versions resolve to the right chain (publish/edit routing and compute ordering alike).
+export function getDdoChainId(ddo: unknown): unknown {
+ const d = ddo as {
+ chainId?: unknown;
+ credentialSubject?: { chainId?: unknown };
+ };
+ return d?.chainId ?? d?.credentialSubject?.chainId;
+}
- const data = JSON.parse(addressFile);
- const chainConfig = Object.values(data).find(
- (network: any) => network.chainId === chainId,
- ) as any;
+/**
+ * The set of chainIds a compute job actually touches: the payment/escrow chain plus
+ * every DID-based dataset/algorithm DDO's own chain (a job may mix assets across
+ * chains, and pay on yet another). Raw `fileObject` entries have a null DDO slot and
+ * no chain (no order is placed for them), so they contribute nothing. Pure + ordered
+ * (payment chain first) so it is unit-testable and its error listing is deterministic.
+ * A *non-null* DDO with no resolvable chainId is malformed — throw with a clear label
+ * rather than silently omitting it (which would bypass the up-front registry validation
+ * and later crash as `orderCtxFor(NaN)` deep in the ordering loop).
+ */
+export function computeJobChainIds(
+ paymentChainId: number,
+ ddos: (Asset | DDO | null | undefined)[],
+ algoDdo?: Asset | DDO | null,
+): number[] {
+ const out: number[] = [];
+ const seen = new Set();
+ const add = (raw: unknown, label: string) => {
+ const id = Number(raw);
+ if (!Number.isInteger(id) || id <= 0) {
+ throw new Error(`Invalid or missing chainId for ${label} (got ${raw}).`);
+ }
+ if (!seen.has(id)) {
+ seen.add(id);
+ out.push(id);
+ }
+ };
+ add(paymentChainId, "payment chain");
+ (ddos || []).forEach((d, i) => {
+ if (d) add(getDdoChainId(d), `dataset ${i}`);
+ });
+ if (algoDdo) add(getDdoChainId(algoDdo), "algorithm");
+ return out;
+}
- if (!chainConfig) {
- throw new Error(`Chain ${chainId} not found in address file`);
+/**
+ * A readable, per-env summary of where a compute env accepts payment: whether it is a
+ * free env, and for a paid one each fee chainId with its accepted fee-token addresses.
+ * Lets a user pick `--chainId` / `--paymentToken` without reading raw JSON. Pure so it
+ * can be unit-tested; operates structurally on the ComputeEnvironment fee shape
+ * (`env.fees[chainId] = [{ feeToken }, ...]`).
+ */
+export function summarizeComputeEnvFees(env: {
+ id?: string;
+ // `free` is truthy (an object/flag) on a free env in ocean.js, not a strict boolean.
+ free?: unknown;
+ fees?: Record;
+}): string {
+ const isFree = Boolean(env?.free);
+ const header = `Env ${env?.id ?? "?"}${isFree ? " (free)" : ""}`;
+ const fees = env?.fees || {};
+ const chains = Object.keys(fees);
+ if (chains.length === 0) {
+ return isFree
+ ? `${header}: no payment required.`
+ : `${header}: no payment chains advertised.`;
}
-
- return chainConfig;
+ const lines = chains.map((chainId) => {
+ const tokens = (fees[chainId] || [])
+ .map((f) => f?.feeToken)
+ .filter((t): t is string => typeof t === "string" && t.length > 0);
+ const tokenList = tokens.length > 0 ? tokens.join(", ") : "(no tokens listed)";
+ return ` chain ${chainId}: ${tokenList}`;
+ });
+ return `${header}: pays on\n${lines.join("\n")}`;
}
+
diff --git a/src/index.ts b/src/index.ts
index 4879146..bfe1907 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,7 @@ import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "readline/promises";
import { createCLI, formatGroupedHelp } from "./cli.js";
import { stopP2P } from "./nodeConnection.js";
+import { destroyProviders } from "./rpcRegistry.js";
let program: Command;
const supportedCommands: string[] = [];
@@ -335,6 +336,7 @@ async function main(): Promise {
// still has buffered, which could swallow the message just written. Exiting
// here (rather than falling through to the finally) keeps failures immediate —
// the process is going away, so libp2p needs no orderly shutdown.
+ await destroyProviders();
await flushOutput();
process.exit(1);
} finally {
@@ -345,6 +347,9 @@ async function main(): Promise {
// process.exit() would discard. Reached on every non-throwing path out of the
// try above; when nothing was started, Node exits on its own and drains the
// streams as part of that.
+ // Providers hold poller timers that also keep the event loop alive — tear them
+ // down too, the same class of problem as the libp2p MessagePort below.
+ await destroyProviders();
if (await stopP2P()) {
await flushOutput();
process.exit(process.exitCode ?? 0);
diff --git a/src/policyServerHelper.ts b/src/policyServerHelper.ts
index a674b38..98247c2 100644
--- a/src/policyServerHelper.ts
+++ b/src/policyServerHelper.ts
@@ -13,6 +13,32 @@ import {
import axios from "axios";
import { Signer } from "ethers";
+// Bounded timeout for the node `status` probe. Without it an unresponsive node
+// would hang the probe (and any download/compute waiting on it) indefinitely.
+const PS_STATUS_PROBE_TIMEOUT_MS = 10_000;
+
+/**
+ * Probe whether the target node has a policy server configured, via the
+ * `status` directCommand. Returns `true` only when the node explicitly reports
+ * `isPSConfigured === true`. On a `false` report, a probe error, or a timeout it
+ * returns `false`, so callers can skip policy-server verification and proceed
+ * rather than hanging or hard-failing on an unresponsive node.
+ */
+export async function isPolicyServerConfigured(
+ providerUrl: string,
+): Promise {
+ try {
+ const statusResponse = await axios.post(
+ `${providerUrl}/directCommand`,
+ { command: "status" },
+ { timeout: PS_STATUS_PROBE_TIMEOUT_MS },
+ );
+ return statusResponse.data?.isPSConfigured === true;
+ } catch {
+ return false;
+ }
+}
+
// Semver-aware "version >= minimum" comparison (numeric, dot-separated). Avoids
// the lexicographic pitfalls of comparing version strings directly (e.g.
// '5.10.0' < '5.9.0' as strings). A missing/empty version is treated as below
@@ -274,13 +300,35 @@ export function extractURLSearchParams(
return params;
}
+/**
+ * Resolve the policy-server object for a single asset/service.
+ *
+ * Returns `null` when policy-server support is unavailable — i.e. the node
+ * reports it has no policy server configured (`isPSConfigured !== true`) — so
+ * callers must treat `null` as "no policy server" and proceed without one. A
+ * probe that fails or times out falls through to the normal flow instead of
+ * masking a real error with `null`.
+ */
export async function getPolicyServerOBJ(
ddo: Asset,
serviceId: string,
signer: Signer,
providerUrl: string,
-): Promise {
+): Promise {
try {
+ try {
+ const statusResponse = await axios.post(
+ `${providerUrl}/directCommand`,
+ { command: "status" },
+ { timeout: PS_STATUS_PROBE_TIMEOUT_MS },
+ );
+ if (statusResponse.data?.isPSConfigured !== true) {
+ return null;
+ }
+ } catch {
+ // Node did not answer the status probe; fall through and attempt the
+ // normal flow rather than masking a real error with a null.
+ }
const accountId = await signer.getAddress();
const presentationResult = await requestCredentialPresentation(
ddo,
@@ -380,6 +428,16 @@ export async function getPolicyServerOBJ(
}
}
+/**
+ * Resolve policy-server objects for a set of datasets plus an optional
+ * algorithm (compute flows).
+ *
+ * Returns `null` when policy-server support is unavailable for the job — any
+ * entry below DDO v5, or any entry whose per-asset lookup yields `null` (node
+ * has no policy server configured). Callers must treat `null` as "no policy
+ * server" and pass it straight through to the provider (which accepts a
+ * nullable `policyServer`).
+ */
export async function getPolicyServerOBJs(
ddos: {
documentId: string;
@@ -410,6 +468,9 @@ export async function getPolicyServerOBJs(
signer,
providerUrl,
);
+ if (!result) {
+ return null;
+ }
results.push({
...result,
documentId: ddo.documentId,
@@ -430,6 +491,9 @@ export async function getPolicyServerOBJs(
signer,
providerUrl,
);
+ if (!algoResult) {
+ return null;
+ }
results.push({
...algoResult,
documentId: algo.documentId,
diff --git a/src/rpcRegistry.ts b/src/rpcRegistry.ts
new file mode 100644
index 0000000..d01785c
--- /dev/null
+++ b/src/rpcRegistry.ts
@@ -0,0 +1,746 @@
+// Runtime RPC registry — the single in-process source of truth for RPC endpoints,
+// providers, signers and per-chain contract config. Mirrors the pattern
+// `nodeConnection.ts` uses for the Ocean Node: seeded from the environment at
+// startup, memoized, torn down on exit.
+//
+// Multi-chain: `RPC` is either a legacy single URL (preserved byte-for-byte) or a JSON
+// map keyed by chainId; a chain with ≥2 URLs is served by a `FallbackProvider`, and
+// contract addresses resolve off-Barge via ocean.js `ConfigHelper`. Runtime `addChain`,
+// `removeChain`, and `setDefaultChainId` mutate the registry and persist to
+// `~/.ocean/cli/rpc.json` (`RPC_CONFIG_FILE` override; env `RPC` merged first, env wins).
+// The chain-management commands are exposed node-free through `cli.ts`.
+import {
+ AbstractProvider,
+ FallbackProvider,
+ FetchRequest,
+ JsonRpcProvider,
+ Network,
+ Signer,
+ Wallet,
+} from "ethers";
+import { Config, ConfigHelper } from "@oceanprotocol/lib";
+import chalk from "chalk";
+import fs from "fs";
+import os from "os";
+import path from "path";
+
+// Per-backend stall timeout: a slow endpoint hands off to the next instead of hanging.
+const STALL_TIMEOUT_MS = 1000;
+
+// Timeout for a one-off chainId probe (startup legacy resolution, verifyChain, addChain),
+// so an unreachable RPC fails fast instead of hanging the CLI.
+const PROBE_TIMEOUT_MS = 5000;
+
+// Options for every JsonRpcProvider the registry builds. `cacheTimeout: -1` disables
+// ethers' 250ms request cache — it also caches getTransactionCount("pending"), so
+// consecutive transactions on a fast chain (ocean.js orderAsset's dispense+order,
+// batched access-list burns, per-asset compute orders) would otherwise reuse a stale
+// nonce and be rejected ("tx doesn't have the correct nonce"). `staticNetwork` skips a
+// per-call eth_chainId (the chainId is known from the map key).
+function providerOpts(network: Network) {
+ return { staticNetwork: network, cacheTimeout: -1 };
+}
+
+const RPC_EXAMPLE =
+ 'a single URL (e.g. "http://localhost:8545") or a JSON map keyed by chainId ' +
+ '(e.g. {"1":"https://eth.example","8453":["https://a","https://b"]}).';
+
+export interface ChainRpc {
+ chainId: number;
+ urls: string[];
+}
+
+export interface ParsedRpc {
+ // Set when RPC was a single URL string (chainId discovered later by probing).
+ legacyUrl?: string;
+ // Set (possibly empty) when RPC was a JSON map keyed by chainId.
+ chains: Map;
+}
+
+// ---------------------------------------------------------------------------
+// Registry state (module-level singletons).
+// ---------------------------------------------------------------------------
+const chainUrls = new Map();
+const providerCache = new Map();
+const signerCache = new Map();
+const configCache = new Map();
+const verifiedChains = new Set();
+let defaultChainId: number | undefined;
+let pendingLegacyUrl: string | undefined;
+let loaded = false;
+let loadedRpcRaw: string | undefined;
+
+// Test seam: how a URL's real chainId is probed. Overridable so the unit tests can
+// exercise the verification logic without a live network.
+export type ChainProbe = (url: string) => Promise;
+async function defaultChainProbe(url: string): Promise {
+ // Wrap the URL in a FetchRequest with an explicit timeout so an unresponsive or
+ // blackholed endpoint fails in seconds instead of hanging the CLI (default network
+ // timeouts can stall startup / addChain for minutes).
+ const req = new FetchRequest(url);
+ req.timeout = PROBE_TIMEOUT_MS;
+ const probe = new JsonRpcProvider(req);
+ try {
+ const hex = await probe.send("eth_chainId", []);
+ return Number(hex);
+ } finally {
+ probe.destroy?.();
+ }
+}
+let chainProbe: ChainProbe = defaultChainProbe;
+
+// ---------------------------------------------------------------------------
+// RPC env parsing (backwards compatible).
+// ---------------------------------------------------------------------------
+function isValidRpcUrl(value: unknown): value is string {
+ if (typeof value !== "string" || value.trim().length === 0) return false;
+ try {
+ const u = new URL(value.trim());
+ return ["http:", "https:", "ws:", "wss:"].includes(u.protocol);
+ } catch {
+ return false;
+ }
+}
+
+function dedupePreserveOrder(urls: string[]): string[] {
+ const seen = new Set();
+ const out: string[] = [];
+ for (const url of urls) {
+ const u = url.trim();
+ if (!seen.has(u)) {
+ seen.add(u);
+ out.push(u);
+ }
+ }
+ return out;
+}
+
+// Non-chain keys tolerated inside a chain map object (the persisted file stores the
+// active default alongside the chains); callers strip these before validating chains.
+const RESERVED_MAP_KEYS = new Set(["defaultChainId"]);
+
+// Validate + normalize a plain object of chainId->url(s) into a deduped, order-preserving
+// Map. Shared by the RPC env parser and the persisted-file loader.
+function parseChainMapObject(
+ parsed: unknown,
+ opts: { requireNonEmpty: boolean },
+): Map {
+ if (Array.isArray(parsed)) {
+ throw new Error(
+ `RPC JSON must be an object keyed by chainId, not an array. Provide ${RPC_EXAMPLE}`,
+ );
+ }
+ if (typeof parsed !== "object" || parsed === null) {
+ throw new Error(`RPC JSON must be an object. Provide ${RPC_EXAMPLE}`);
+ }
+ const entries = Object.entries(parsed as Record).filter(
+ ([k]) => !RESERVED_MAP_KEYS.has(k),
+ );
+ if (opts.requireNonEmpty && entries.length === 0) {
+ throw new Error(`RPC JSON map is empty. Provide ${RPC_EXAMPLE}`);
+ }
+ const chains = new Map();
+ for (const [key, value] of entries) {
+ const chainId = Number(key);
+ if (!Number.isInteger(chainId) || chainId <= 0) {
+ throw new Error(
+ `Invalid chainId key "${key}" in RPC map; keys must be positive integers. Provide ${RPC_EXAMPLE}`,
+ );
+ }
+ let urls: unknown[];
+ if (typeof value === "string") urls = [value];
+ else if (Array.isArray(value)) urls = value;
+ else
+ throw new Error(
+ `RPC entry for chain ${chainId} must be a URL string or a non-empty array of URL strings. Provide ${RPC_EXAMPLE}`,
+ );
+ if (urls.length === 0) {
+ throw new Error(
+ `RPC entry for chain ${chainId} is empty; give at least one URL. Provide ${RPC_EXAMPLE}`,
+ );
+ }
+ for (const u of urls) {
+ if (!isValidRpcUrl(u)) {
+ throw new Error(
+ `RPC entry for chain ${chainId} has an invalid URL (${JSON.stringify(
+ u,
+ )}); expected an http(s)/ws(s) URL. Provide ${RPC_EXAMPLE}`,
+ );
+ }
+ }
+ chains.set(chainId, dedupePreserveOrder(urls as string[]));
+ }
+ return chains;
+}
+
+// Parse the `RPC` env value into either a legacy single URL or a chainId->urls map.
+// Throws with a clear, example-bearing message on any malformed shape. The unset
+// message is kept verbatim ("Have you forgot to set env RPC?") because it is asserted
+// by test/setup.test.ts.
+export function parseRpcEnv(raw?: string): ParsedRpc {
+ if (raw === undefined || raw === null || raw.trim().length === 0) {
+ throw new Error("Have you forgot to set env RPC?");
+ }
+ const trimmed = raw.trim();
+
+ // Legacy single-URL form: anything not starting with { or [ is one URL verbatim.
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
+ if (!isValidRpcUrl(trimmed)) {
+ throw new Error(
+ `RPC "${trimmed}" is not a valid http(s)/ws(s) URL. Provide ${RPC_EXAMPLE}`,
+ );
+ }
+ return { legacyUrl: trimmed, chains: new Map() };
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ throw new Error(
+ `RPC looks like JSON but could not be parsed. Provide ${RPC_EXAMPLE}`,
+ );
+ }
+
+ const chains = parseChainMapObject(parsed, { requireNonEmpty: true });
+ return { legacyUrl: undefined, chains };
+}
+
+// ---------------------------------------------------------------------------
+// Registry lifecycle.
+// ---------------------------------------------------------------------------
+
+// Synchronously tear down every cached provider and clear all memo maps.
+function teardownProviders(): void {
+ for (const provider of providerCache.values()) {
+ try {
+ (provider as { destroy?: () => void }).destroy?.();
+ } catch {
+ // best effort — never let teardown throw
+ }
+ }
+ providerCache.clear();
+ signerCache.clear();
+ configCache.clear();
+ verifiedChains.clear();
+ chainUrls.clear();
+}
+
+// ---------------------------------------------------------------------------
+// Persistence — runtime-added chains survive a restart (~/.ocean/cli/rpc.json,
+// overridable via RPC_CONFIG_FILE). Same JSON-map shape as `RPC`, plus a top-level
+// `defaultChainId`. All I/O is defensive: a persistence failure never breaks a command
+// whose blockchain work already succeeded.
+// ---------------------------------------------------------------------------
+function persistFilePath(): string {
+ return (
+ process.env.RPC_CONFIG_FILE ||
+ path.join(os.homedir(), ".ocean", "cli", "rpc.json")
+ );
+}
+
+interface PersistedConfig {
+ chains: Map;
+ defaultChainId?: number;
+}
+
+function readPersistedConfig(): PersistedConfig {
+ const file = persistFilePath();
+ let raw: string;
+ try {
+ if (!fs.existsSync(file)) return { chains: new Map() };
+ raw = fs.readFileSync(file, "utf-8");
+ } catch (e) {
+ console.warn(
+ chalk.yellow(
+ `Could not read RPC config file ${file} (${
+ (e as Error).message
+ }) — ignoring it.`,
+ ),
+ );
+ return { chains: new Map() };
+ }
+ try {
+ const parsed = JSON.parse(raw) as Record;
+ const chains = parseChainMapObject(parsed, { requireNonEmpty: false });
+ const dc = parsed.defaultChainId;
+ const defaultChain =
+ typeof dc === "number" && Number.isInteger(dc) && dc > 0 ? dc : undefined;
+ return { chains, defaultChainId: defaultChain };
+ } catch (e) {
+ console.warn(
+ chalk.yellow(
+ `RPC config file ${file} is malformed (${
+ (e as Error).message
+ }) — ignoring it.`,
+ ),
+ );
+ return { chains: new Map() };
+ }
+}
+
+function persistConfig(): void {
+ const file = persistFilePath();
+ const obj: Record = {};
+ for (const [cid, urls] of chainUrls) obj[String(cid)] = urls;
+ if (defaultChainId !== undefined) obj.defaultChainId = defaultChainId;
+ try {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2));
+ } catch (e) {
+ console.warn(
+ chalk.yellow(
+ `Could not write RPC config file ${file} (${
+ (e as Error).message
+ }) — the chain change will not survive a restart.`,
+ ),
+ );
+ }
+}
+
+// Seed the registry from the `RPC` env. Idempotent: repeated calls with an unchanged
+// `RPC` are a no-op, so providers/signers are reused across REPL commands. Pass
+// `force` to re-seed regardless.
+export function loadRegistry(force = false): void {
+ const raw = process.env.RPC;
+ if (!force && loaded && raw === loadedRpcRaw) return;
+
+ teardownProviders();
+ defaultChainId = undefined;
+ pendingLegacyUrl = undefined;
+
+ const parsed = parseRpcEnv(raw);
+ if (parsed.legacyUrl) {
+ pendingLegacyUrl = parsed.legacyUrl;
+ } else {
+ for (const [cid, urls] of parsed.chains) chainUrls.set(cid, urls);
+ }
+
+ // Merge the persisted file (runtime-added chains survive a restart). Env wins on
+ // conflict, so CI and env-driven runs stay deterministic regardless of what a prior
+ // interactive session persisted.
+ const persisted = readPersistedConfig();
+ for (const [cid, urls] of persisted.chains) {
+ if (!chainUrls.has(cid)) chainUrls.set(cid, urls);
+ }
+
+ // Default resolution (steps 1–2 of the plan; the node∩registry step needs node chains
+ // and is resolved lazily by resolveDefaultChain). CHAIN_ID env → persisted default →
+ // sole configured chain. A legacy single URL has no known chainId yet, so its default
+ // is settled later by ensureDefaultChain's probe.
+ const envDefault = process.env.CHAIN_ID
+ ? Number(process.env.CHAIN_ID)
+ : undefined;
+ if (envDefault && chainUrls.has(envDefault)) {
+ defaultChainId = envDefault;
+ } else if (
+ persisted.defaultChainId &&
+ chainUrls.has(persisted.defaultChainId)
+ ) {
+ defaultChainId = persisted.defaultChainId;
+ } else if (!pendingLegacyUrl && chainUrls.size === 1) {
+ defaultChainId = [...chainUrls.keys()][0];
+ }
+
+ loaded = true;
+ loadedRpcRaw = raw;
+}
+
+// Resolve the default (active) chain. For a legacy single URL the chainId is
+// discovered by probing it (via the mockable, timeout-protected `chainProbe`), then the
+// URL is registered under it. For a single-entry map, that entry is the default.
+export async function ensureDefaultChain(): Promise {
+ if (!loaded) loadRegistry();
+ if (defaultChainId !== undefined) return defaultChainId;
+
+ if (pendingLegacyUrl) {
+ const url = pendingLegacyUrl;
+ try {
+ const cid = await chainProbe(url);
+ chainUrls.set(cid, [url]);
+ // chainProbe just confirmed the chain — no need to re-verify on first use.
+ verifiedChains.add(cid);
+ defaultChainId = cid;
+ pendingLegacyUrl = undefined;
+ return cid;
+ } catch (e) {
+ throw new Error(
+ `Could not verify legacy RPC URL ${url}: ${(e as Error).message}`,
+ { cause: e },
+ );
+ }
+ }
+
+ const keys = [...chainUrls.keys()];
+ if (keys.length === 1) {
+ defaultChainId = keys[0];
+ return keys[0];
+ }
+ throw new Error(
+ `No default chain configured. Configured chains: ${
+ keys.join(", ") || "none"
+ }.`,
+ );
+}
+
+export function getDefaultChainId(): number | undefined {
+ return defaultChainId;
+}
+
+export function setDefaultChainId(id: number): void {
+ if (!chainUrls.has(id)) {
+ throw new Error(
+ `Cannot set default chain ${id}: it is not configured. Configured chains: ${
+ listChains()
+ .map((c) => c.chainId)
+ .join(", ") || "none"
+ }.`,
+ );
+ }
+ defaultChainId = id;
+ persistConfig();
+}
+
+// Full default-chain resolution (plan §"Default (active) chain"): explicit default
+// (setChain / CHAIN_ID / persisted / sole chain) → the single chain that both the node
+// serves and the registry knows → undefined. `nodeChains` is optional so the registry
+// stays decoupled from the node; the CLI passes it in when it has node status.
+export function resolveDefaultChain(nodeChains?: number[]): number | undefined {
+ if (defaultChainId !== undefined) return defaultChainId;
+ const keys = [...chainUrls.keys()];
+ if (keys.length === 1) return keys[0];
+ if (nodeChains && nodeChains.length > 0) {
+ const intersection = keys.filter((k) => nodeChains.includes(k));
+ if (intersection.length === 1) return intersection[0];
+ }
+ return undefined;
+}
+
+// The chain to sign chain-agnostic commands on. Prefers the real default; falls back to
+// *any* registered chain purely to obtain a signer (plan §"Default chain" step 4) without
+// committing it as the default. Probes a legacy single URL exactly as before.
+export async function getActiveChainId(): Promise {
+ if (!loaded) loadRegistry();
+ if (defaultChainId !== undefined) return defaultChainId;
+ if (pendingLegacyUrl) return ensureDefaultChain();
+ const keys = [...chainUrls.keys()];
+ if (keys.length === 1) {
+ defaultChainId = keys[0];
+ return keys[0];
+ }
+ if (keys.length > 1) return keys[0]; // any — for signing only, not made the default
+ throw new Error("No RPC chains configured.");
+}
+
+// Register a chain at runtime: verify EACH url actually serves `chainId` (probe
+// eth_chainId), then store (dedup + order; ≥2 urls → FallbackProvider) and persist.
+// Rejects a url on a different chain — the up-front verification the plan calls for.
+export async function addChain(
+ chainId: number,
+ urls: string[],
+): Promise {
+ if (!loaded) loadRegistry();
+ // If a legacy single-URL `RPC` hasn't been probed yet, resolve it first so adding a
+ // new chain neither orphans the legacy chain nor steals its default slot.
+ if (pendingLegacyUrl) {
+ try {
+ await ensureDefaultChain();
+ } catch {
+ // Legacy URL unreachable right now — proceed; the new chain can still register.
+ }
+ }
+ if (!Number.isInteger(chainId) || chainId <= 0) {
+ throw new Error(`Invalid chainId ${chainId}: must be a positive integer.`);
+ }
+ const deduped = dedupePreserveOrder(urls.filter((u) => isValidRpcUrl(u)));
+ if (deduped.length === 0) {
+ throw new Error(
+ `No valid http(s)/ws(s) RPC URL given for chain ${chainId}.`,
+ );
+ }
+ for (const url of deduped) {
+ let actual: number;
+ try {
+ actual = await chainProbe(url);
+ } catch (e) {
+ throw new Error(
+ `Could not reach ${url} to verify chain ${chainId}: ${
+ (e as Error).message
+ }`,
+ { cause: e },
+ );
+ }
+ if (actual !== chainId) {
+ throw new Error(
+ `${url} serves chainId ${actual}, not ${chainId} — refusing to register it.`,
+ );
+ }
+ }
+ chainUrls.set(chainId, deduped);
+ providerCache.delete(chainId);
+ signerCache.delete(chainId);
+ configCache.delete(chainId);
+ verifiedChains.add(chainId); // just verified above
+ // Only become the default when there isn't one already (a recovered legacy chain, or a
+ // prior setChain, keeps precedence).
+ if (defaultChainId === undefined) defaultChainId = chainId;
+ persistConfig();
+}
+
+// Unregister a chain + persist. Refuses to remove the only configured chain (it would
+// leave the CLI with nowhere to sign); clears the default if it pointed here.
+export function removeChain(chainId: number): void {
+ if (!loaded) loadRegistry();
+ if (!chainUrls.has(chainId)) {
+ throw new Error(
+ `Chain ${chainId} is not configured. Configured chains: ${
+ listChains()
+ .map((c) => c.chainId)
+ .join(", ") || "none"
+ }.`,
+ );
+ }
+ if (chainUrls.size === 1) {
+ throw new Error(
+ `Refusing to remove the only configured chain ${chainId}. Add another chain first.`,
+ );
+ }
+ // A chain that comes from the `RPC` env var will be re-merged on the next startup
+ // ("env wins" — a deliberate determinism decision), so removing it here is only for
+ // this session unless the user also edits `RPC`. Warn rather than silently misleading.
+ try {
+ const fromEnv = parseRpcEnv(process.env.RPC);
+ if (fromEnv.chains.has(chainId)) {
+ console.warn(
+ chalk.yellow(
+ `Chain ${chainId} is listed in the RPC env var and will reappear on the next ` +
+ `start (env config wins). Remove it from RPC to drop it permanently.`,
+ ),
+ );
+ }
+ } catch {
+ // RPC unparseable/absent — nothing to warn about; proceed with the removal.
+ }
+ chainUrls.delete(chainId);
+ providerCache.delete(chainId);
+ signerCache.delete(chainId);
+ configCache.delete(chainId);
+ verifiedChains.delete(chainId);
+ if (defaultChainId === chainId) {
+ const remaining = [...chainUrls.keys()];
+ defaultChainId = remaining.length === 1 ? remaining[0] : undefined;
+ }
+ persistConfig();
+}
+
+export function hasChain(chainId: number): boolean {
+ return chainUrls.has(chainId);
+}
+
+export function listChains(): ChainRpc[] {
+ return [...chainUrls.entries()].map(([chainId, urls]) => ({
+ chainId,
+ urls: [...urls],
+ }));
+}
+
+// ---------------------------------------------------------------------------
+// Provider / signer / config construction (memoized per chain).
+// ---------------------------------------------------------------------------
+
+// Pure, testable builder for the ethers v6 FallbackProvider arguments. Encodes the
+// four easy-to-get-wrong points: quorum:1 (default would require agreement, the
+// opposite of fallback), priority=index (declaration order = preference), a per-backend
+// stallTimeout, and a staticNetwork on every inner provider (chainId is known from the
+// map key, so construction doesn't depend on a backend being up right now).
+// `cacheTimeout: -1` disables ethers' 250ms request cache: it also caches
+// getTransactionCount("pending"), so back-to-back transactions on a fast chain (e.g.
+// ocean.js orderAsset's dispense+order, or batched access-list burns) would otherwise
+// reuse a stale nonce and be rejected. See PROVIDER_OPTS.
+export function buildFallbackConfigs(
+ urls: string[],
+ chainId: number,
+): {
+ configs: {
+ provider: JsonRpcProvider;
+ priority: number;
+ stallTimeout: number;
+ weight: number;
+ }[];
+ options: { quorum: number };
+ network: Network;
+} {
+ const network = Network.from(chainId);
+ const configs = urls.map((url, index) => ({
+ provider: new JsonRpcProvider(url, network, providerOpts(network)),
+ priority: index,
+ stallTimeout: STALL_TIMEOUT_MS,
+ weight: 1,
+ }));
+ return { configs, options: { quorum: 1 }, network };
+}
+
+export function getProvider(chainId: number): AbstractProvider {
+ const cached = providerCache.get(chainId);
+ if (cached) return cached;
+
+ const urls = chainUrls.get(chainId);
+ if (!urls || urls.length === 0) {
+ throw new Error(
+ `No RPC configured for chain ${chainId}. Configured chains: ${
+ listChains()
+ .map((c) => c.chainId)
+ .join(", ") || "none"
+ }.`,
+ );
+ }
+
+ const network = Network.from(chainId);
+ let provider: AbstractProvider;
+ if (urls.length === 1) {
+ provider = new JsonRpcProvider(urls[0], network, providerOpts(network));
+ } else {
+ const { configs, options } = buildFallbackConfigs(urls, chainId);
+ provider = new FallbackProvider(configs, network, options);
+ }
+ providerCache.set(chainId, provider);
+ return provider;
+}
+
+// Verify the declared chain once, lazily, on first use. Confirmed-mismatched endpoints
+// are dropped with a yellow warning; an endpoint unreachable right now is kept (the
+// FallbackProvider fails over from it at runtime). Hard-error only if every endpoint is
+// confirmed to be on the wrong chain — signing against the wrong chain is the worst
+// failure this feature could introduce.
+export async function verifyChain(chainId: number): Promise {
+ if (verifiedChains.has(chainId)) return;
+ const urls = chainUrls.get(chainId);
+ if (!urls || urls.length === 0) {
+ throw new Error(`No RPC configured for chain ${chainId}.`);
+ }
+
+ const kept: string[] = [];
+ for (const url of urls) {
+ try {
+ const actual = await chainProbe(url);
+ if (actual === chainId) {
+ kept.push(url);
+ } else {
+ console.warn(
+ chalk.yellow(
+ `RPC ${url} reports chainId ${actual}, expected ${chainId} — dropping it.`,
+ ),
+ );
+ }
+ } catch (e) {
+ // Unreachable right now: keep it for runtime failover rather than dropping.
+ console.warn(
+ chalk.yellow(
+ `RPC ${url} for chain ${chainId} could not be verified now (${
+ (e as Error).message
+ }) — keeping it for runtime failover.`,
+ ),
+ );
+ kept.push(url);
+ }
+ }
+
+ if (kept.length === 0) {
+ throw new Error(
+ `Every configured RPC for chain ${chainId} reports a different chainId — refusing to sign.`,
+ );
+ }
+ if (kept.length !== urls.length) {
+ chainUrls.set(chainId, kept);
+ providerCache.delete(chainId);
+ }
+ verifiedChains.add(chainId);
+}
+
+// The Wallet credential logic mirrors the original initializeSigner() exactly:
+// PRIVATE_KEY preferred, else MNEMONIC via Wallet.fromPhrase.
+export async function getSigner(chainId: number): Promise {
+ const cached = signerCache.get(chainId);
+ if (cached) return cached;
+
+ await verifyChain(chainId);
+ const provider = getProvider(chainId);
+
+ let signer: Signer;
+ if (process.env.PRIVATE_KEY) {
+ signer = new Wallet(process.env.PRIVATE_KEY, provider);
+ } else if (process.env.MNEMONIC) {
+ signer = Wallet.fromPhrase(process.env.MNEMONIC, provider);
+ } else {
+ throw new Error("Have you forgot to set MNEMONIC or PRIVATE_KEY?");
+ }
+
+ signerCache.set(chainId, signer);
+ return signer;
+}
+
+// Per-chain ocean.js config. `ConfigHelper` already resolves contract addresses from
+// ADDRESS_FILE (Barge / custom) else the bundled multi-chain contracts, so this is the
+// single source for escrow / accessListFactory / oceanTokenAddress.
+// Returns null for a chain ocean.js ConfigHelper does not know and no ADDRESS_FILE
+// entry supplies — callers must guard (see `requireAddress` / `Commands.configFor`).
+export function getConfigFor(chainId: number): Config | null {
+ const cached = configCache.get(chainId);
+ if (cached) return cached;
+
+ const config = new ConfigHelper().getConfig(chainId);
+ if (config) {
+ config.nodeUri = process.env.NODE_URL;
+ configCache.set(chainId, config);
+ }
+ return config;
+}
+
+// Resolve a required contract address for a chain, or throw a clear, actionable error
+// (instead of failing deep inside an ethers call on an undefined address).
+export function requireAddress(
+ chainId: number,
+ field: "escrow" | "oceanTokenAddress" | "accessListFactory",
+ label: string,
+): string {
+ const config = getConfigFor(chainId);
+ const address = config?.[field];
+ if (!address) {
+ const hint =
+ field === "oceanTokenAddress"
+ ? "Pass --token for this chain."
+ : "Set ADDRESS_FILE to a deployment for this chain, or use a supported chain.";
+ throw new Error(
+ `${label} address not found for chain ${chainId}. ${hint} Configured chains: ${
+ listChains()
+ .map((c) => c.chainId)
+ .join(", ") || "none"
+ }.`,
+ );
+ }
+ return address;
+}
+
+// Tear down every provider (they hold timers that keep the event loop alive) and reset
+// the registry, for a clean process exit. Wired into index.ts alongside stopP2P().
+export async function destroyProviders(): Promise {
+ teardownProviders();
+ defaultChainId = undefined;
+ pendingLegacyUrl = undefined;
+ loaded = false;
+ loadedRpcRaw = undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Test-only helpers.
+// ---------------------------------------------------------------------------
+export function __setChainProbeForTests(fn: ChainProbe | null): void {
+ chainProbe = fn ?? defaultChainProbe;
+}
+export function __resetRegistryForTests(): void {
+ teardownProviders();
+ defaultChainId = undefined;
+ pendingLegacyUrl = undefined;
+ loaded = false;
+ loadedRpcRaw = undefined;
+}
diff --git a/src/serviceHelpers.ts b/src/serviceHelpers.ts
index ebd0057..9e1f06a 100644
--- a/src/serviceHelpers.ts
+++ b/src/serviceHelpers.ts
@@ -15,7 +15,7 @@ import {
ServiceTemplatePublic,
TemplateResourceRequirement,
} from "@oceanprotocol/lib";
-import { getConfigByChainId } from "./helpers.js";
+import { getConfigFor } from "./rpcRegistry.js";
// ---------------------------------------------------------------------------
// 4.1 Status labels
@@ -260,17 +260,17 @@ export async function verifyServiceEscrow(
durationSeconds: number,
): Promise {
try {
- const config = await getConfigByChainId(chainId);
- if (!config?.Escrow) {
+ const config = getConfigFor(chainId);
+ if (!config?.escrow) {
console.error(
chalk.red(
- `Escrow contract address not found for chain ${chainId} in the address file.`,
+ `Escrow contract address not found for chain ${chainId}. Set ADDRESS_FILE to a deployment for this chain, or use a supported chain.`,
),
);
return false;
}
const escrow = new EscrowContract(
- getAddress(config.Escrow),
+ getAddress(config.escrow),
signer,
chainId,
);
diff --git a/test/accessList.test.ts b/test/accessList.test.ts
index 4428c91..382b6b1 100644
--- a/test/accessList.test.ts
+++ b/test/accessList.test.ts
@@ -1,7 +1,7 @@
import { expect } from "chai";
import { homedir } from "os";
import { runCommand } from "./util.js";
-import { getConfigByChainId } from "../src/helpers.js";
+import { getConfigFor } from "../src/rpcRegistry.js";
import { JsonRpcProvider, ethers } from "ethers";
import { AccessListContract, AccesslistFactory } from "@oceanprotocol/lib";
@@ -22,7 +22,7 @@ describe("Ocean CLI Access List", function () {
process.env.NODE_URL = "http://127.0.0.1:8001";
process.env.ADDRESS_FILE = `${homedir}/.ocean/ocean-contracts/artifacts/address.json`;
- chainConfig = await getConfigByChainId(8996);
+ chainConfig = getConfigFor(8996);
const provider = new JsonRpcProvider(process.env.RPC);
owner = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
@@ -247,7 +247,7 @@ describe("Ocean CLI Access List", function () {
describe("Access List Factory", function () {
it("should verify access list is deployed via factory", async function () {
const factory = new AccesslistFactory(
- chainConfig.AccessListFactory,
+ chainConfig.accessListFactory,
owner,
chainConfig.chainId,
);
@@ -258,7 +258,7 @@ describe("Ocean CLI Access List", function () {
it("should verify access list is soulbound", async function () {
const factory = new AccesslistFactory(
- chainConfig.AccessListFactory,
+ chainConfig.accessListFactory,
owner,
chainConfig.chainId,
);
diff --git a/test/computeChains.unit.test.ts b/test/computeChains.unit.test.ts
new file mode 100644
index 0000000..130994a
--- /dev/null
+++ b/test/computeChains.unit.test.ts
@@ -0,0 +1,99 @@
+import { expect } from "chai";
+import {
+ computeJobChainIds,
+ summarizeComputeEnvFees,
+ getDdoChainId,
+} from "../src/helpers.js";
+
+// Pure unit tests for the Phase 3 multi-chain-compute helpers. No infra.
+
+describe("computeJobChainIds", () => {
+ it("returns just the payment chain when there are no DID assets", () => {
+ expect(computeJobChainIds(137, [null, null], null)).to.deep.equal([137]);
+ });
+
+ it("collects the payment chain plus each DID asset/algo chain, payment first", () => {
+ const ddos = [{ chainId: 8996 }, { chainId: 137 }];
+ const algo = { chainId: 1 };
+ expect(computeJobChainIds(10, ddos, algo)).to.deep.equal([
+ 10, 8996, 137, 1,
+ ]);
+ });
+
+ it("de-dups chains and preserves first-seen order", () => {
+ const ddos = [{ chainId: 137 }, { chainId: 137 }];
+ const algo = { chainId: 137 };
+ // payment chain 137 seen first; the rest collapse into it
+ expect(computeJobChainIds(137, ddos, algo)).to.deep.equal([137]);
+ });
+
+ it("ignores raw fileObject entries (null/undefined DDO slots)", () => {
+ const ddos = [null, { chainId: 8996 }, undefined];
+ expect(computeJobChainIds(137, ddos, null)).to.deep.equal([137, 8996]);
+ });
+
+ it("reads a v5 DDO's chainId from credentialSubject", () => {
+ const ddos = [{ credentialSubject: { chainId: 8996 } }];
+ const algo = { credentialSubject: { chainId: 137 } };
+ expect(computeJobChainIds(10, ddos, algo)).to.deep.equal([10, 8996, 137]);
+ });
+
+ it("throws on a non-null DDO with no resolvable chainId (malformed)", () => {
+ const ddos = [{ chainId: 8996 }, { chainId: undefined }];
+ expect(() => computeJobChainIds(137, ddos, null)).to.throw(
+ /Invalid or missing chainId for dataset 1/i,
+ );
+ });
+
+ it("is equivalent to a single chain when every asset shares the payment chain", () => {
+ const ddos = [{ chainId: 8996 }, { chainId: 8996 }];
+ const algo = { chainId: 8996 };
+ // single-chain back-compat: only one chain to validate/order on
+ expect(computeJobChainIds(8996, ddos, algo)).to.deep.equal([8996]);
+ });
+});
+
+describe("getDdoChainId", () => {
+ it("reads a top-level chainId (4.1.0 DDO)", () => {
+ expect(getDdoChainId({ chainId: 8996 })).to.equal(8996);
+ });
+ it("reads credentialSubject.chainId (v5 DDO)", () => {
+ expect(getDdoChainId({ credentialSubject: { chainId: 137 } })).to.equal(
+ 137,
+ );
+ });
+ it("returns undefined when neither is present", () => {
+ expect(getDdoChainId({})).to.equal(undefined);
+ });
+});
+
+describe("summarizeComputeEnvFees", () => {
+ it("lists each fee chain with its accepted tokens", () => {
+ const out = summarizeComputeEnvFees({
+ id: "env-1",
+ fees: {
+ "8996": [{ feeToken: "0xAAA" }, { feeToken: "0xBBB" }],
+ "137": [{ feeToken: "0xCCC" }],
+ },
+ });
+ expect(out).to.contain("Env env-1");
+ expect(out).to.contain("chain 8996: 0xAAA, 0xBBB");
+ expect(out).to.contain("chain 137: 0xCCC");
+ });
+
+ it("marks a free env and reports no payment required", () => {
+ const out = summarizeComputeEnvFees({ id: "free-env", free: {}, fees: {} });
+ expect(out).to.contain("(free)");
+ expect(out).to.contain("no payment required");
+ });
+
+ it("reports when a paid env advertises no fee chains", () => {
+ const out = summarizeComputeEnvFees({ id: "paid-env", fees: {} });
+ expect(out).to.contain("no payment chains advertised");
+ });
+
+ it("tolerates a fee entry with no listed tokens", () => {
+ const out = summarizeComputeEnvFees({ id: "e", fees: { "1": [] } });
+ expect(out).to.contain("chain 1: (no tokens listed)");
+ });
+});
diff --git a/test/escrow.test.ts b/test/escrow.test.ts
index 2651e9b..2582225 100644
--- a/test/escrow.test.ts
+++ b/test/escrow.test.ts
@@ -1,7 +1,7 @@
import { expect } from "chai";
import { homedir } from "os";
import { runCommand } from "./util.js";
-import { getConfigByChainId } from "../src/helpers.js";
+import { getConfigFor } from "../src/rpcRegistry.js";
import { JsonRpcProvider, ethers, formatEther, getAddress } from "ethers";
import { EscrowContract } from "@oceanprotocol/lib";
@@ -22,9 +22,9 @@ describe("Ocean CLI Escrow", function () {
process.env.NODE_URL = "http://127.0.0.1:8001";
process.env.ADDRESS_FILE = `${homedir}/.ocean/ocean-contracts/artifacts/address.json`;
- chainConfig = await getConfigByChainId(8996);
- tokenAddress = chainConfig.Ocean;
- escrowAddress = chainConfig.Escrow;
+ chainConfig = getConfigFor(8996);
+ tokenAddress = chainConfig.oceanTokenAddress;
+ escrowAddress = chainConfig.escrow;
const provider = new JsonRpcProvider(process.env.RPC);
payer = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
diff --git a/test/replMenu.test.ts b/test/replMenu.test.ts
index 28dcf57..90788d7 100644
--- a/test/replMenu.test.ts
+++ b/test/replMenu.test.ts
@@ -1,4 +1,7 @@
import { expect } from "chai";
+import fs from "fs";
+import os from "os";
+import path from "path";
import { REPL_PROMPT as PROMPT, runRepl } from "./util.js";
describe("Ocean CLI interactive menu (REPL)", function () {
@@ -77,4 +80,32 @@ describe("Ocean CLI interactive menu (REPL)", function () {
expect(output).to.not.contain("too many arguments");
expect(output).to.contain("Command error");
});
+
+ it("runs the node-free chain commands (listChains / setChain / getChain)", async function () {
+ // A JSON-map RPC with two chains, and an isolated persistence file so the test
+ // never touches the real ~/.ocean/cli/rpc.json. All three commands are node-free,
+ // so they reach the gate and run with no live infra.
+ const tmpFile = path.join(
+ fs.mkdtempSync(path.join(os.tmpdir(), "repl-rpc-")),
+ "rpc.json",
+ );
+ const { output } = await runRepl(
+ ["listChains", "setChain 137", "getChain", "exit"],
+ {
+ env: {
+ RPC: '{"8996":"http://127.0.0.1:1","137":"http://127.0.0.1:2"}',
+ RPC_CONFIG_FILE: tmpFile,
+ CHAIN_ID: undefined,
+ },
+ },
+ );
+ expect(output).to.contain("Configured RPC chains:");
+ expect(output).to.contain("8996");
+ expect(output).to.contain("137");
+ expect(output).to.contain("Default chain is now 137");
+ expect(output).to.contain("Default chain: 137");
+ // The switch was persisted to the isolated file.
+ const persisted = JSON.parse(fs.readFileSync(tmpFile, "utf-8"));
+ expect(persisted.defaultChainId).to.equal(137);
+ });
});
diff --git a/test/rpcRegistry.test.ts b/test/rpcRegistry.test.ts
new file mode 100644
index 0000000..8b056f0
--- /dev/null
+++ b/test/rpcRegistry.test.ts
@@ -0,0 +1,430 @@
+import { expect } from "chai";
+import { FallbackProvider, JsonRpcProvider } from "ethers";
+import fs from "fs";
+import os from "os";
+import path from "path";
+import {
+ parseRpcEnv,
+ buildFallbackConfigs,
+ loadRegistry,
+ getProvider,
+ verifyChain,
+ listChains,
+ hasChain,
+ getDefaultChainId,
+ addChain,
+ removeChain,
+ setDefaultChainId,
+ resolveDefaultChain,
+ __setChainProbeForTests,
+ __resetRegistryForTests,
+} from "../src/rpcRegistry.js";
+
+// Pure unit tests — no live network. Provider construction is lazy in ethers v6, and
+// the chainId verification path is exercised through an injected probe seam
+// (__setChainProbeForTests), so nothing here dials an RPC.
+
+describe("rpcRegistry — parseRpcEnv", function () {
+ it("keeps a legacy single-URL string verbatim (backwards compatible)", function () {
+ const parsed = parseRpcEnv("http://localhost:8545");
+ expect(parsed.legacyUrl).to.equal("http://localhost:8545");
+ expect(parsed.chains.size).to.equal(0);
+ });
+
+ it("rejects a malformed legacy single URL (fail-fast, not deferred)", function () {
+ expect(() => parseRpcEnv("not-a-url")).to.throw(/not a valid/i);
+ expect(() => parseRpcEnv("ftp://nope.example")).to.throw(/not a valid/i);
+ });
+
+ it("throws the verbatim message when RPC is unset", function () {
+ expect(() => parseRpcEnv(undefined)).to.throw(
+ "Have you forgot to set env RPC?",
+ );
+ expect(() => parseRpcEnv(" ")).to.throw("Have you forgot to set env RPC?");
+ });
+
+ it("parses a JSON map with string and array values", function () {
+ const parsed = parseRpcEnv(
+ '{"1":"https://eth.example","8453":["https://a.example","https://b.example"]}',
+ );
+ expect(parsed.legacyUrl).to.equal(undefined);
+ expect([...parsed.chains.get(1)!]).to.deep.equal(["https://eth.example"]);
+ expect([...parsed.chains.get(8453)!]).to.deep.equal([
+ "https://a.example",
+ "https://b.example",
+ ]);
+ });
+
+ it("de-dupes URLs within a chain while preserving order", function () {
+ const parsed = parseRpcEnv(
+ '{"8996":["http://a.example","http://b.example","http://a.example"]}',
+ );
+ expect(parsed.chains.get(8996)).to.deep.equal([
+ "http://a.example",
+ "http://b.example",
+ ]);
+ });
+
+ it("accepts ws(s) URLs", function () {
+ const parsed = parseRpcEnv('{"1":"wss://eth.example/ws"}');
+ expect(parsed.chains.get(1)).to.deep.equal(["wss://eth.example/ws"]);
+ });
+
+ it("rejects a top-level array", function () {
+ expect(() => parseRpcEnv('["http://a"]')).to.throw(/not an array/i);
+ });
+
+ it("rejects an empty object", function () {
+ expect(() => parseRpcEnv("{}")).to.throw(/empty/i);
+ });
+
+ it("rejects a non-integer / non-positive chainId key", function () {
+ expect(() => parseRpcEnv('{"abc":"http://a.example"}')).to.throw(
+ /chainId/i,
+ );
+ expect(() => parseRpcEnv('{"-1":"http://a.example"}')).to.throw(/chainId/i);
+ });
+
+ it("rejects an empty url array for a chain", function () {
+ expect(() => parseRpcEnv('{"1":[]}')).to.throw(/empty/i);
+ });
+
+ it("rejects a non-string url entry", function () {
+ expect(() => parseRpcEnv('{"1":[123]}')).to.throw(/invalid url/i);
+ });
+
+ it("rejects an invalid (non-http/ws) url", function () {
+ expect(() => parseRpcEnv('{"1":"ftp://nope.example"}')).to.throw(
+ /invalid url/i,
+ );
+ expect(() => parseRpcEnv('{"1":"not a url"}')).to.throw(/invalid url/i);
+ });
+
+ it("rejects malformed JSON that looks like JSON", function () {
+ expect(() => parseRpcEnv('{"1": }')).to.throw(/could not be parsed/i);
+ });
+});
+
+describe("rpcRegistry — buildFallbackConfigs", function () {
+ it("uses quorum:1, ascending priorities, and a per-backend stallTimeout", function () {
+ const { configs, options, network } = buildFallbackConfigs(
+ ["http://a.example", "http://b.example", "http://c.example"],
+ 8453,
+ );
+ expect(options.quorum).to.equal(1);
+ expect(configs.map((c) => c.priority)).to.deep.equal([0, 1, 2]);
+ for (const c of configs) {
+ expect(c.stallTimeout).to.be.a("number").that.is.greaterThan(0);
+ expect(c.provider).to.be.instanceOf(JsonRpcProvider);
+ }
+ expect(Number(network.chainId)).to.equal(8453);
+ });
+});
+
+describe("rpcRegistry — registry from env", function () {
+ const origRpc = process.env.RPC;
+ const origFile = process.env.RPC_CONFIG_FILE;
+ const origChainId = process.env.CHAIN_ID;
+
+ beforeEach(function () {
+ // Isolate from a leaked persisted file / default-chain override so listChains()
+ // and getDefaultChainId() assertions here are deterministic.
+ process.env.RPC_CONFIG_FILE = path.join(
+ fs.mkdtempSync(path.join(os.tmpdir(), "rpcreg-")),
+ "rpc.json",
+ );
+ delete process.env.CHAIN_ID;
+ });
+
+ afterEach(function () {
+ __resetRegistryForTests();
+ __setChainProbeForTests(null);
+ if (origRpc === undefined) delete process.env.RPC;
+ else process.env.RPC = origRpc;
+ if (origFile === undefined) delete process.env.RPC_CONFIG_FILE;
+ else process.env.RPC_CONFIG_FILE = origFile;
+ if (origChainId === undefined) delete process.env.CHAIN_ID;
+ else process.env.CHAIN_ID = origChainId;
+ });
+
+ it("seeds a single-chain map and marks it the default", function () {
+ process.env.RPC = '{"8996":["http://localhost:8545"]}';
+ loadRegistry(true);
+ expect(hasChain(8996)).to.equal(true);
+ expect(getDefaultChainId()).to.equal(8996);
+ expect(listChains()).to.deep.equal([
+ { chainId: 8996, urls: ["http://localhost:8545"] },
+ ]);
+ });
+
+ it("builds a plain JsonRpcProvider for a single-URL chain", function () {
+ process.env.RPC = '{"8996":["http://localhost:8545"]}';
+ loadRegistry(true);
+ const provider = getProvider(8996);
+ expect(provider).to.be.instanceOf(JsonRpcProvider);
+ });
+
+ it("builds a FallbackProvider (quorum 1) for a multi-URL chain", function () {
+ process.env.RPC =
+ '{"8453":["http://a.example","http://b.example"]}';
+ loadRegistry(true);
+ const provider = getProvider(8453);
+ expect(provider).to.be.instanceOf(FallbackProvider);
+ const priorities = (provider as FallbackProvider).providerConfigs.map(
+ (c) => c.priority,
+ );
+ expect(priorities).to.deep.equal([0, 1]);
+ });
+
+ it("does not set a default when several chains are configured", function () {
+ process.env.RPC =
+ '{"1":"http://a.example","8453":"http://b.example"}';
+ loadRegistry(true);
+ expect(getDefaultChainId()).to.equal(undefined);
+ expect(listChains().map((c) => c.chainId).sort()).to.deep.equal([1, 8453]);
+ });
+});
+
+describe("rpcRegistry — verifyChain (mocked probe)", function () {
+ const origRpc = process.env.RPC;
+ const origFile = process.env.RPC_CONFIG_FILE;
+ const origChainId = process.env.CHAIN_ID;
+
+ beforeEach(function () {
+ // Point persistence at a fresh temp file (and clear CHAIN_ID) so loadRegistry(true)
+ // can't merge a real ~/.ocean/cli/rpc.json into these listChains() assertions.
+ process.env.RPC_CONFIG_FILE = path.join(
+ fs.mkdtempSync(path.join(os.tmpdir(), "rpcreg-")),
+ "rpc.json",
+ );
+ delete process.env.CHAIN_ID;
+ });
+
+ afterEach(function () {
+ __resetRegistryForTests();
+ __setChainProbeForTests(null);
+ if (origRpc === undefined) delete process.env.RPC;
+ else process.env.RPC = origRpc;
+ if (origFile === undefined) delete process.env.RPC_CONFIG_FILE;
+ else process.env.RPC_CONFIG_FILE = origFile;
+ if (origChainId === undefined) delete process.env.CHAIN_ID;
+ else process.env.CHAIN_ID = origChainId;
+ });
+
+ it("drops a backend that reports the wrong chainId and keeps the good one", async function () {
+ process.env.RPC =
+ '{"8453":["http://right.example","http://wrong.example"]}';
+ loadRegistry(true);
+ __setChainProbeForTests(async (url) =>
+ url.includes("right") ? 8453 : 999,
+ );
+ await verifyChain(8453);
+ expect(listChains()).to.deep.equal([
+ { chainId: 8453, urls: ["http://right.example"] },
+ ]);
+ });
+
+ it("hard-errors when every backend is on the wrong chain", async function () {
+ process.env.RPC =
+ '{"8453":["http://wrong1.example","http://wrong2.example"]}';
+ loadRegistry(true);
+ __setChainProbeForTests(async () => 111);
+ let threw = false;
+ try {
+ await verifyChain(8453);
+ } catch (e) {
+ threw = true;
+ expect((e as Error).message).to.match(/different chainId/i);
+ }
+ expect(threw).to.equal(true);
+ });
+
+ it("keeps an unreachable backend for runtime failover", async function () {
+ process.env.RPC =
+ '{"8453":["http://up.example","http://down.example"]}';
+ loadRegistry(true);
+ __setChainProbeForTests(async (url) => {
+ if (url.includes("down")) throw new Error("ECONNREFUSED");
+ return 8453;
+ });
+ await verifyChain(8453);
+ expect(listChains()[0].urls).to.deep.equal([
+ "http://up.example",
+ "http://down.example",
+ ]);
+ });
+});
+
+describe("rpcRegistry — addChain / removeChain (mocked probe)", function () {
+ const origRpc = process.env.RPC;
+ const origFile = process.env.RPC_CONFIG_FILE;
+ const origChainId = process.env.CHAIN_ID;
+ let tmpFile: string;
+
+ beforeEach(function () {
+ tmpFile = path.join(
+ fs.mkdtempSync(path.join(os.tmpdir(), "rpcreg-")),
+ "rpc.json",
+ );
+ process.env.RPC_CONFIG_FILE = tmpFile;
+ delete process.env.CHAIN_ID;
+ // Every probed URL reports the chainId embedded in its host segment `cid-`.
+ __setChainProbeForTests(async (url) => {
+ const m = url.match(/cid-(\d+)/);
+ return m ? Number(m[1]) : 8996;
+ });
+ });
+
+ afterEach(function () {
+ __resetRegistryForTests();
+ __setChainProbeForTests(null);
+ if (origRpc === undefined) delete process.env.RPC;
+ else process.env.RPC = origRpc;
+ if (origFile === undefined) delete process.env.RPC_CONFIG_FILE;
+ else process.env.RPC_CONFIG_FILE = origFile;
+ if (origChainId === undefined) delete process.env.CHAIN_ID;
+ else process.env.CHAIN_ID = origChainId;
+ });
+
+ it("registers a chain whose URL serves it, and persists to the config file", async function () {
+ process.env.RPC = '{"8996":"http://cid-8996.example"}';
+ loadRegistry(true);
+ await addChain(137, ["http://cid-137.example"]);
+ expect(hasChain(137)).to.equal(true);
+ const written = JSON.parse(fs.readFileSync(tmpFile, "utf-8"));
+ expect(written["137"]).to.deep.equal(["http://cid-137.example"]);
+ });
+
+ it("resolves a pending legacy URL before adding, keeping legacy as default", async function () {
+ process.env.RPC = "http://cid-8996.example"; // legacy single URL, not yet probed
+ loadRegistry(true);
+ await addChain(137, ["http://cid-137.example"]);
+ const ids = listChains()
+ .map((c) => c.chainId)
+ .sort((a, b) => a - b);
+ expect(ids).to.deep.equal([137, 8996]); // legacy not orphaned
+ expect(getDefaultChainId()).to.equal(8996); // legacy keeps the default slot
+ });
+
+ it("rejects a URL that serves a different chain", async function () {
+ process.env.RPC = '{"8996":"http://cid-8996.example"}';
+ loadRegistry(true);
+ let threw = false;
+ try {
+ await addChain(137, ["http://cid-999.example"]);
+ } catch (e) {
+ threw = true;
+ expect((e as Error).message).to.contain("999");
+ }
+ expect(threw).to.equal(true);
+ expect(hasChain(137)).to.equal(false);
+ });
+
+ it("refuses to remove the only configured chain, but removes one of several", async function () {
+ process.env.RPC =
+ '{"8996":"http://cid-8996.example","137":"http://cid-137.example"}';
+ loadRegistry(true);
+ removeChain(137);
+ expect(hasChain(137)).to.equal(false);
+ expect(hasChain(8996)).to.equal(true);
+ let threw = false;
+ try {
+ removeChain(8996);
+ } catch {
+ threw = true;
+ }
+ expect(threw).to.equal(true);
+ expect(hasChain(8996)).to.equal(true);
+ });
+});
+
+describe("rpcRegistry — persistence merge + default precedence", function () {
+ const origRpc = process.env.RPC;
+ const origFile = process.env.RPC_CONFIG_FILE;
+ const origChainId = process.env.CHAIN_ID;
+ let tmpFile: string;
+
+ beforeEach(function () {
+ tmpFile = path.join(
+ fs.mkdtempSync(path.join(os.tmpdir(), "rpcreg-")),
+ "rpc.json",
+ );
+ process.env.RPC_CONFIG_FILE = tmpFile;
+ delete process.env.CHAIN_ID;
+ __setChainProbeForTests(async (url) => {
+ const m = url.match(/cid-(\d+)/);
+ return m ? Number(m[1]) : 8996;
+ });
+ });
+
+ afterEach(function () {
+ __resetRegistryForTests();
+ __setChainProbeForTests(null);
+ if (origRpc === undefined) delete process.env.RPC;
+ else process.env.RPC = origRpc;
+ if (origFile === undefined) delete process.env.RPC_CONFIG_FILE;
+ else process.env.RPC_CONFIG_FILE = origFile;
+ if (origChainId === undefined) delete process.env.CHAIN_ID;
+ else process.env.CHAIN_ID = origChainId;
+ });
+
+ it("merges persisted chains with env, env winning on conflict", function () {
+ fs.writeFileSync(
+ tmpFile,
+ JSON.stringify({
+ "8996": ["http://persisted-8996.example"],
+ "137": ["http://cid-137.example"],
+ }),
+ );
+ process.env.RPC = '{"8996":"http://env-8996.example"}';
+ loadRegistry(true);
+ // 137 comes only from the file; 8996 keeps the env URL (env wins).
+ expect(hasChain(137)).to.equal(true);
+ const c8996 = listChains().find((c) => c.chainId === 8996);
+ expect(c8996?.urls).to.deep.equal(["http://env-8996.example"]);
+ });
+
+ it("honors a persisted defaultChainId when registered", function () {
+ fs.writeFileSync(
+ tmpFile,
+ JSON.stringify({
+ "8996": ["http://cid-8996.example"],
+ "137": ["http://cid-137.example"],
+ defaultChainId: 137,
+ }),
+ );
+ process.env.RPC = '{"8996":"http://cid-8996.example"}';
+ loadRegistry(true);
+ expect(getDefaultChainId()).to.equal(137);
+ });
+
+ it("CHAIN_ID env wins over a persisted default", function () {
+ fs.writeFileSync(
+ tmpFile,
+ JSON.stringify({
+ "137": ["http://cid-137.example"],
+ defaultChainId: 137,
+ }),
+ );
+ process.env.RPC = '{"8996":"http://cid-8996.example"}';
+ process.env.CHAIN_ID = "8996";
+ loadRegistry(true);
+ expect(getDefaultChainId()).to.equal(8996);
+ });
+
+ it("a single configured chain is the default with no other signal", function () {
+ process.env.RPC = '{"8996":"http://cid-8996.example"}';
+ loadRegistry(true);
+ expect(getDefaultChainId()).to.equal(8996);
+ });
+
+ it("resolveDefaultChain falls back to node∩registry when exactly one matches", function () {
+ process.env.RPC =
+ '{"8996":"http://cid-8996.example","137":"http://cid-137.example"}';
+ loadRegistry(true);
+ expect(getDefaultChainId()).to.equal(undefined); // two chains, no explicit default
+ expect(resolveDefaultChain([137, 999])).to.equal(137); // only 137 is both served & configured
+ setDefaultChainId(8996);
+ expect(resolveDefaultChain([137])).to.equal(8996); // explicit default wins
+ });
+});
diff --git a/test/setup.test.ts b/test/setup.test.ts
index a63d3a6..126b27e 100644
--- a/test/setup.test.ts
+++ b/test/setup.test.ts
@@ -152,4 +152,32 @@ describe("Ocean CLI Setup", function () {
},
);
});
+
+ it("should reject a malformed JSON RPC map with a clear message", function (done) {
+ const projectRoot = path.resolve(__dirname, "..");
+ process.env.PRIVATE_KEY =
+ "0x1d751ded5a32226054cd2e71261039b65afb9ee1c746d055dd699b1150a5befc";
+ delete process.env.MNEMONIC;
+ // A top-level array is a valid RPC-shaped input that must be rejected.
+ process.env.RPC = '["http://127.0.0.1:8545"]';
+
+ exec(
+ "npm run cli getDDO did:op:123",
+ { cwd: projectRoot },
+ (error, stdout, stderr) => {
+ try {
+ const out = `${stdout}${stderr}`;
+ expect(out).to.match(/not an array/i);
+ // The old "Have you forgot to set env RPC?" message must NOT appear for a
+ // set-but-malformed value.
+ expect(out).to.not.contain("Have you forgot to set env RPC?");
+ done();
+ } catch (assertionError) {
+ done(assertionError);
+ } finally {
+ process.env.RPC = "http://127.0.0.1:8545";
+ }
+ },
+ );
+ });
});