diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 887b08e..dbe6d54 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -127,7 +127,6 @@ jobs:
with:
repository: 'oceanprotocol/barge'
path: 'barge'
- ref: "feature/node-v4"
- name: Login to Docker Hub
if: ${{ env.DOCKERHUB_PASSWORD && env.DOCKERHUB_USERNAME }}
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 dda87c1..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.
```
@@ -200,6 +237,16 @@ npm run cli [options]
The node is health-checked before the switch: if it cannot be reached, the current node is kept and nothing changes.
+
+
+- **Select a node *and* a compute env in one step (from a search result):**
+ `npm run cli setNodeEnv 16Uiu2HAm...|0xff10...-0xc92e...` (alias `useNodeEnv`).
+ Paste the `Node+env: |` token printed by `searchComputeResources` (the leading
+ `Node+env:` label is tolerated). It validates and switches to the node exactly like `setNode`,
+ and additionally **remembers the compute env** so a following `startCompute` / `startFreeCompute`
+ uses it by default — you don't repeat `--env`. Override any time with an explicit `--env`. Like
+ `setNode`, on an unreachable node nothing changes (node and env are both left as they were).
+
- **Show the node in use:**
@@ -210,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.
---
@@ -271,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.
---
@@ -289,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.
@@ -324,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`
@@ -344,12 +412,66 @@ 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 `
---
+**Search Compute Resources (alias `findComputeNodes`):**
+
+ Discover which nodes on the network can run your compute job, filtered by the resources you
+ need, by free vs paid, and — for paid — by chain / token / price, with matches ordered by
+ your preference. This is a **P2P/DHT search** (no HTTP equivalent), so it needs libp2p and
+ cannot run with `DISABLE_P2P=true`; it works even on an HTTP-configured node and **before**
+ `setNode` (it's a natural way to *find* a node to select).
+
+- **Interactive wizard** (default — run with no filter flags):
+
+ `npm run cli searchComputeResources`
+
+- **Scripted (non-interactive) with flags:**
+
+ ```bash
+ npm run cli searchComputeResources --cpu 4 --ram 16 --gpu 1 --gpu-model A100 \
+ --paid --chain 8996 --token 0x... --max-price 5 --duration 3600 --order-by price
+ ```
+
+ `--chain` and `--token` accept comma-separated lists. Pricing is computed per chain, and a
+ paid environment that prices on several of the requested chains is **listed once per chain**
+ (each with that chain's cheapest token) so you can compare the same env across chains. The
+ `--token` list is applied as the filter on each chain (per-chain token sets are only
+ expressible through the wizard):
+
+ ```bash
+ npm run cli searchComputeResources --cpu 4 --paid --chain 8996,137 --token 0xOCEAN,0xUSDC
+ ```
+
+- **Open-ended resource, free tier:**
+
+ `npm run cli searchComputeResources --resource fpga:2 --free --order-by leastBusy`
+
+ `cpu | ram | disk | gpu` are the known resource names; `--resource name:amount` (repeatable)
+ accepts any other resource name (e.g. `fpga`, `tpu`). With no tier flag, both free and paid
+ are searched. Results are filtered to genuine matches — environments whose resources fall short
+ of the request, or (paid) that don't price on one of the requested chains with an accepted
+ token, are dropped, and a note reports how many were filtered out. Each match's **first line is
+ a ready-to-run `setNodeEnv |` command**, followed by per-resource `need`/`max`,
+ accepted tokens + estimated cost (paid), running/queued jobs, and a stable `PROVIDER …` line.
+ Copy-paste that first line to select both the node and the compute env in one step, then just
+ run `startCompute …` — the env is remembered, no `--env` needed.
+
+ ```text
+ setNodeEnv 16Uiu2HAmR9z4…|0xff10…-0xc92e…
+ [paid] cpu: need 1, max 4
+ estimated cost: 0.003 0x1c7D…7238 on chain 8996
+ ...
+ ```
+
+---
+
**Get Compute Streamable Logs:**
`npm run cli computeStreamableLogs`
@@ -659,7 +781,10 @@ Notes:
`` (Positional. HTTP(S) URL, peer id or full multiaddr)
`-n, --node ` (Same as the positional)
-- **getNode** (alias `currentNode`)**:** no arguments
+- **setNodeEnv** (alias `useNodeEnv`)**:**
+ `` (Positional. A `node|env` token from a `searchComputeResources` result; a leading `Node+env:` label is tolerated. Sets the node and remembers the compute env for later `startCompute`.)
+
+- **getNode** (alias `currentNode`)**:** no arguments (prints the active node and, if set, the remembered compute env)
- **getDDO:**
`-d, --did `
@@ -691,11 +816,12 @@ Notes:
- **startCompute:**
`-d, --datasets `
`-a, --algo `
- `-e, --env `
+ `-e, --env ` (Optional if a compute env was selected via `setNodeEnv`; explicit value wins)
`--init `
`--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)
@@ -704,14 +830,26 @@ Notes:
- **startFreeCompute:**
`-d, --datasets `
`-a, --algo `
- `-e, --env `
+ `-e, --env ` (Optional if a compute env was selected via `setNodeEnv`; explicit value wins)
`-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`)
+- **searchComputeResources:** (alias `findComputeNodes`)
+ `--cpu ` `--ram ` `--disk ` `--gpu ` (each optional; at least one resource required to skip the wizard)
+ `--gpu-model ` (Optional. GPU kind/description to match, e.g. A100)
+ `--resource ` (Optional, repeatable. Any resource name, e.g. `fpga:2`)
+ `--free` / `--paid` / `--both` (Optional tier; default both)
+ `--chain ` (Optional. Comma-separated chainIds to price against; defaults to the RPC chain)
+ `--token ` (Optional. Comma-separated payment-token addresses; applied to every `--chain`)
+ `--max-price ` (Optional. Drop paid results above this, in human units)
+ `--duration ` (Optional. Assumed job duration for the cost estimate)
+ `--order-by ` (Optional. `price` | `freeCapacity` | `resources` | `leastBusy`)
+
- **computeStreamableLogs:**
- **stopCompute:**
diff --git a/src/cli.ts b/src/cli.ts
index ef54683..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";
@@ -15,17 +14,213 @@ import {
import { parseComputeInput, toBoolean } from "./helpers.js";
import {
getCurrentNodeUrl,
+ getCurrentEnvId,
hasNode,
nodeChainIds,
setCurrentNodeUrl,
+ setCurrentEnvId,
+ clearCurrentEnvId,
startP2P,
validateNode,
} from "./nodeConnection.js";
+import {
+ buildParamsFromFlags,
+ hasSearchFlags,
+ 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
// (useNode, currentNode, h) resolve to these.
-const NODE_FREE_COMMANDS = new Set(["setNode", "getNode", "help"]);
+const NODE_FREE_COMMANDS = new Set([
+ "setNode",
+ "setNodeEnv",
+ "getNode",
+ "help",
+ // 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
+// list is printed (`help`, bare `--help`, and the no-arg startup banner), never behavior. Names
+// are canonical (aliases are looked up and shown from each command). `assertHelpGroupsCoverAll`
+// keeps this in lockstep with the registered commands, so adding a command without grouping it
+// fails at startup rather than dropping it silently from help.
+interface HelpSubgroup {
+ subheading: string;
+ commands: string[];
+}
+interface HelpGroup {
+ heading: string;
+ commands?: string[];
+ subgroups?: HelpSubgroup[];
+}
+const HELP_GROUPS: HelpGroup[] = [
+ {
+ heading: "Node & session",
+ commands: ["setNode", "setNodeEnv", "getNode", "help"],
+ },
+ {
+ 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"],
+ },
+ {
+ heading: "Compute",
+ subgroups: [
+ {
+ subheading: "Jobs (C2D)",
+ commands: [
+ "startCompute",
+ "startFreeCompute",
+ "getJobStatus",
+ "computeStreamableLogs",
+ "downloadJobResults",
+ "stopCompute",
+ ],
+ },
+ {
+ subheading: "Services on demand",
+ commands: [
+ "getServiceTemplates",
+ "startService",
+ "getServiceStatus",
+ "getServices",
+ "serviceLogs",
+ "extendService",
+ "restartService",
+ "stopService",
+ ],
+ },
+ ],
+ },
+ {
+ heading: "Tokens & auth",
+ commands: ["mintOcean", "generateAuthToken", "invalidateAuthToken"],
+ },
+ {
+ heading: "Escrow payments",
+ commands: [
+ "depositEscrow",
+ "getUserFundsEscrow",
+ "withdrawFromEscrow",
+ "authorizeEscrow",
+ "getAuthorizationsEscrow",
+ ],
+ },
+ {
+ heading: "Access lists",
+ commands: [
+ "createAccessList",
+ "addToAccessList",
+ "checkAccessList",
+ "removeFromAccessList",
+ ],
+ },
+ {
+ heading: "Persistent storage (buckets)",
+ commands: [
+ "createBucket",
+ "addFileToBucket",
+ "listBuckets",
+ "listFilesInBucket",
+ "getFileObject",
+ "deleteFile",
+ ],
+ },
+ { heading: "Admin", commands: ["downloadNodeLogs"] },
+];
+
+// Every command name mentioned across all groups/subgroups, in listing order.
+function groupedCommandNames(): string[] {
+ const names: string[] = [];
+ for (const g of HELP_GROUPS) {
+ for (const n of g.commands ?? []) names.push(n);
+ for (const s of g.subgroups ?? []) names.push(...s.commands);
+ }
+ return names;
+}
+
+// Fail fast if the groups drift from the registered commands: a command in no group (would
+// vanish from help), one grouped twice, or a group naming a command that no longer exists.
+function assertHelpGroupsCoverAll(program: Command): void {
+ const grouped = groupedCommandNames();
+ const dupes = [...new Set(grouped.filter((n, i) => grouped.indexOf(n) !== i))];
+ const registered = program.commands.map((c) => c.name());
+ const missing = registered.filter((n) => !grouped.includes(n));
+ const unknown = grouped.filter((n) => !registered.includes(n));
+ const problems: string[] = [];
+ if (dupes.length) problems.push(`listed in more than one group: ${dupes.join(", ")}`);
+ if (missing.length) problems.push(`not in any help group: ${missing.join(", ")}`);
+ if (unknown.length) problems.push(`grouped but not registered: ${unknown.join(", ")}`);
+ if (problems.length) {
+ throw new Error(`Help groups out of sync — ${problems.join("; ")}`);
+ }
+}
+
+// Render the topic-grouped command listing used by `help` and the startup banner.
+export function formatGroupedHelp(program: Command): string {
+ const byName = new Map(program.commands.map((c) => [c.name(), c] as const));
+ const line = (name: string): string => {
+ const cmd = byName.get(name);
+ const aliases = cmd?.aliases() ?? [];
+ const label = aliases.length ? `${name} (${aliases.join(", ")})` : name;
+ const desc = cmd?.description() ?? "";
+ const gap = label.length < 38 ? " ".repeat(38 - label.length) : " ";
+ return ` ${label}${gap}${desc}`;
+ };
+
+ const out: string[] = [chalk.bold(`Ocean CLI v${pkg.version} — commands`), ""];
+ for (const group of HELP_GROUPS) {
+ out.push(chalk.cyan.bold(group.heading));
+ for (const n of group.commands ?? []) out.push(line(n));
+ for (const sub of group.subgroups ?? []) {
+ out.push(chalk.cyan(` ${sub.subheading}:`));
+ for (const n of sub.commands) out.push(line(n));
+ }
+ out.push("");
+ }
+ out.push(
+ chalk.gray(
+ "Both positional args and named options work for every command. Add -h/--help after a command for its options, e.g. publish --help",
+ ),
+ );
+ return out.join("\n");
+}
// Single source of truth for the CLI version: read it from package.json instead
// of hardcoding, so it can't drift. `../package.json` resolves from both src/
@@ -62,18 +257,80 @@ function parsePorts(value: string): number[] {
});
}
+// Commander collector for a repeatable `--resource name:amount` option.
+function collectResource(value: string, previous: string[]): string[] {
+ return previous.concat([value]);
+}
+
+// Run the interactive resource-search wizard, but only when we have a TTY — a wizard with
+// no stdin would hang. In a non-interactive context, tell the user to pass filter flags.
+async function runResourceWizard(chainId: number) {
+ if (!input.isTTY) {
+ throw new Error(
+ "searchComputeResources needs either filter flags (e.g. --cpu 4 --paid) or an " +
+ "interactive terminal for the wizard. Run with --help to see the flags.",
+ );
+ }
+ 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() {
@@ -101,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
@@ -179,14 +445,13 @@ export async function createCLI() {
}
});
- // Custom help command to support legacy "h" invocation.
- // Note: We use console.log(program.helpInformation()) to print the full help output.
+ // Custom help command to support legacy "h" invocation. Prints the topic-grouped listing.
program
.command("help")
.alias("h")
.description("Display help for all commands")
.action(() => {
- console.log(program.helpInformation());
+ console.log(formatGroupedHelp(program));
});
// setNode command. The switch itself never touches the RPC/signer: choosing a node is
@@ -221,6 +486,9 @@ export async function createCLI() {
}
setCurrentNodeUrl(target);
+ // The node changed (equal targets returned early above), so any env remembered by
+ // setNodeEnv belonged to the previous node and no longer applies.
+ clearCurrentEnvId();
console.log(
chalk.green(`Using node: ${target} (version ${status.version})`),
);
@@ -254,6 +522,61 @@ export async function createCLI() {
}
});
+ // setNodeEnv command: paste a search result's "node|env" token to select BOTH the Ocean Node
+ // and the compute environment for subsequent compute commands in one step. Mirrors setNode
+ // (validate then switch the node) and additionally remembers the env id.
+ program
+ .command("setNodeEnv")
+ .alias("useNodeEnv")
+ .description(
+ "Select both an Ocean Node and a compute environment from a search result's 'node|env' token",
+ )
+ .argument(
+ "",
+ "A 'node|env' token from a searchComputeResources result (node URL/peer id, a '|', then the env id)",
+ )
+ .action(async (nodeEnv) => {
+ // Tolerate a pasted "Node+env: |" line, not just the bare token.
+ const raw = nodeEnv.replace(/^\s*Node\+env:\s*/i, "").trim();
+ const sep = raw.indexOf("|");
+ if (sep <= 0 || sep === raw.length - 1) {
+ console.error(
+ chalk.red(
+ "Expected a 'node|env' token (node, a '|', then the compute env id), e.g. 16Uiu2…|0xabc…-0xdef…",
+ ),
+ );
+ return;
+ }
+ const target = raw.slice(0, sep).trim();
+ const envId = raw.slice(sep + 1).trim();
+
+ const status = await validateNode(target);
+ if (!status) {
+ const previous = getCurrentNodeUrl();
+ console.error(
+ chalk.red(
+ previous
+ ? `Cannot reach ${target}. Keeping current node: ${previous} (env unchanged)`
+ : `Cannot reach ${target}. Still no node set.`,
+ ),
+ );
+ return;
+ }
+
+ setCurrentNodeUrl(target);
+ setCurrentEnvId(envId);
+ console.log(
+ chalk.green(
+ `Using node: ${target} (version ${status.version})\nUsing compute env: ${envId}`,
+ ),
+ );
+ console.log(
+ chalk.yellow(
+ "startCompute / startFreeCompute will use this env by default (override with --env).",
+ ),
+ );
+ });
+
// getNode command
program
.command("getNode")
@@ -270,17 +593,205 @@ export async function createCLI() {
return;
}
console.log(`Current Ocean Node: ${current}`);
+ const envId = getCurrentEnvId();
+ if (envId) {
+ console.log(`Selected compute env: ${envId}`);
+ }
// 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")
@@ -418,10 +929,13 @@ export async function createCLI() {
"",
"Algorithm DID, OR a JSON ComputeAlgorithm object with a fileObject and meta (raw algorithm, no DID)",
)
- .argument("", "Compute environment ID")
- .argument("", "maxJobDuration for compute job")
- .argument("", "Payment token for compute")
- .argument("", "Resources of compute environment stringified")
+ .argument(
+ "[computeEnvId]",
+ "Compute environment ID (optional if one was selected via setNodeEnv). If omitted, pass the later values (maxJobDuration, paymentToken, resources, ...) as named options rather than positionally, since they would otherwise fill this slot.",
+ )
+ .argument("[maxJobDuration]", "maxJobDuration for compute job")
+ .argument("[paymentToken]", "Payment token for compute")
+ .argument("[resources]", "Resources of compute environment stringified")
.argument(
"[output]",
"Output backend to save job results to. Supported types include S3, FTP, URL, Arweave, etc. Defaults to node local disk if omitted.",
@@ -456,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.",
@@ -475,13 +993,29 @@ export async function createCLI() {
) => {
const dsDids = options.datasets || datasetDids;
const aDid = options.algo || algoDid;
- const envId = options.env || computeEnvId;
+ // Fall back to the env remembered by setNodeEnv when none is given explicitly.
+ const envId = options.env || computeEnvId || getCurrentEnvId();
const jobDuration = options.maxJobDuration || maxJobDuration;
const token = options.token || paymentToken;
const res = options.resources || resources;
const outputLocation = options.output || output;
const svcIds = options.services ?? serviceIds ?? "";
const algoSvcId = options.algoService ?? algoServiceId ?? "";
+
+ // A compute env id is never a bare number, so a numeric positional here almost
+ // always means the user omitted the env (relying on setNodeEnv) and let the next
+ // value — maxJobDuration — shift up into this slot. Catch that early with an
+ // actionable message instead of failing later with a misleading env error.
+ if (!options.env && computeEnvId && /^\d+$/.test(computeEnvId.trim())) {
+ console.error(
+ chalk.red(
+ `"${computeEnvId}" looks like maxJobDuration, not a compute environment ID. ` +
+ "If you meant to use the env selected via setNodeEnv, pass the remaining values as named options " +
+ "(--maxJobDuration, --token, --resources). Otherwise provide the environment ID explicitly (--env ).",
+ ),
+ );
+ return;
+ }
if (!dsDids || !aDid || !envId || !jobDuration || !token || !res) {
console.error(chalk.red("Missing required arguments"));
// process.exit(1);
@@ -513,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,
@@ -526,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."));
@@ -535,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(),
);
@@ -581,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."));
+ }
},
);
@@ -598,7 +1151,10 @@ export async function createCLI() {
"",
"Algorithm DID, OR a JSON ComputeAlgorithm object with a fileObject and meta (raw algorithm, no DID)",
)
- .argument("", "Compute environment ID")
+ .argument(
+ "[computeEnvId]",
+ "Compute environment ID (optional if one was selected via setNodeEnv)",
+ )
.argument(
"[output]",
"Output backend to save job results to. Supported types include S3, FTP, URL, Arweave, etc. Defaults to node local disk if omitted.",
@@ -629,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,
@@ -641,7 +1201,8 @@ export async function createCLI() {
) => {
const dsDids = options.datasets || datasetDids;
const aDid = options.algo || algoDid;
- const envId = options.env || computeEnvId;
+ // Fall back to the env remembered by setNodeEnv when none is given explicitly.
+ const envId = options.env || computeEnvId || getCurrentEnvId();
const outputLocation = options.output || output;
const svcIds = options.services ?? serviceIds ?? "";
const algoSvcId = options.algoService ?? algoServiceId ?? "";
@@ -676,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,
@@ -879,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;
@@ -918,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,
@@ -1065,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;
@@ -1087,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);
});
@@ -1229,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
@@ -1265,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"));
@@ -1290,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);
});
@@ -1304,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);
});
@@ -1333,6 +1919,7 @@ export async function createCLI() {
"-c, --maxLockCounts ",
"Maximum number of locks allowed",
)
+ .option("--chainId ", "Escrow chain (default: active chain)")
.action(
async (
token,
@@ -1344,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;
@@ -1374,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,
@@ -1410,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,
@@ -1434,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,
@@ -1456,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,
@@ -1478,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,
@@ -1596,5 +2194,53 @@ export async function createCLI() {
await commands.deleteFile([null, bucketId, fileName]);
});
+ program
+ .command("searchComputeResources")
+ .alias("findComputeNodes")
+ .description(
+ "Search the network for compute providers that can run a job with the resources you need. " +
+ "Runs an interactive wizard when no filter flags are given, otherwise uses the flags.",
+ )
+ .option("--cpu ", "CPU cores needed")
+ .option("--ram ", "RAM needed")
+ .option("--disk ", "Disk needed")
+ .option("--gpu ", "GPU devices needed")
+ .option("--gpu-model ", "GPU kind/description to match (e.g. A100)")
+ .option(
+ "--resource ",
+ "Arbitrary resource (e.g. fpga:2); repeatable",
+ collectResource,
+ [] as string[],
+ )
+ .option("--free", "Search only free compute environments")
+ .option("--paid", "Search only paid compute environments")
+ .option("--both", "Search both free and paid (default)")
+ .option(
+ "--chain ",
+ "Chain(s) to price against, comma-separated (defaults to the RPC chain)",
+ )
+ .option(
+ "--token ",
+ "Restrict to payment-token address(es), comma-separated; applied to every --chain",
+ )
+ .option("--max-price ", "Drop paid results costing more than this (human units)")
+ .option("--duration ", "Assumed job duration for the cost estimate")
+ .option(
+ "--order-by ",
+ "Order results: price | freeCapacity | resources | leastBusy",
+ )
+ .action(async (options) => {
+ const { signer, chainId } = await initializeSigner();
+ const commands = new Commands(signer, chainId);
+ const flags = options as SearchFlags;
+ const params = hasSearchFlags(flags)
+ ? buildParamsFromFlags(flags, chainId)
+ : await runResourceWizard(chainId);
+ await commands.searchComputeResources(params);
+ });
+
+ // Every registered command must belong to exactly one help group (see HELP_GROUPS).
+ assertHelpGroupsCoverAll(program);
+
return program;
}
diff --git a/src/commands.ts b/src/commands.ts
index 9384dc1..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,
@@ -31,6 +41,7 @@ import {
AccesslistFactory,
AccessListContract,
ComputeResourceRequest,
+ ComputeSearchDimensionResult,
ServiceJob,
ServiceJobListed,
ServiceRestartParams,
@@ -48,6 +59,7 @@ import chalk from "chalk";
import {
getPolicyServerOBJ,
getPolicyServerOBJs,
+ isPolicyServerConfigured,
isVersionGte,
} from "./policyServerHelper.js";
import {
@@ -64,9 +76,26 @@ import {
statusLabel,
isTerminal,
} from "./serviceHelpers.js";
+import { ensureP2PReady, getSearchSeedNode } from "./nodeConnection.js";
+import {
+ ResourceSearchParams,
+ ProviderEnvRow,
+ buildRows,
+ filterMatches,
+ applyMaxPrice,
+ orderRows,
+ printRows,
+ printDimensionDiagnostics,
+} from "./searchResourcesHelpers.js";
const UPLOAD_TIMEOUT_MS = 30 * 60_000;
+// Upper bound for a single compute-provider DHT search (per tier). A DHT lookup keeps querying
+// the network for the full window rather than returning early, so in practice this is also how
+// long each tier takes. 60s matches the lib's per-query DHT timeout default; overridable via
+// SEARCH_TIMEOUT_MS.
+const SEARCH_TIMEOUT_MS = 60_000;
+
// A node log endpoint streams in follow mode: it stays open for as long as the
// container lives, so reading it to the end never returns. Left unbounded, undici
// eventually kills the body with UND_ERR_BODY_TIMEOUT and everything buffered so
@@ -102,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);
@@ -109,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
@@ -134,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();
@@ -168,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);
@@ -223,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,
@@ -251,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(
@@ -265,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,
@@ -290,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(
@@ -338,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],
@@ -350,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[] = [];
@@ -529,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(", "),
@@ -575,7 +870,7 @@ export class Commands {
const policiesServer = await getPolicyServerOBJs(
assetsForPolicy,
assetAlgo,
- this.signer,
+ paymentSigner,
this.oceanNodeUrl,
);
const parsedResources = JSON.parse(resources);
@@ -587,7 +882,7 @@ export class Commands {
paymentToken,
supportedMaxJobDuration,
providerURI,
- await this.signer.getAddress(),
+ await paymentSigner.getAddress(),
parsedResources,
Number(chainId),
policiesServer,
@@ -609,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],
@@ -621,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[] = [];
@@ -795,7 +1131,7 @@ export class Commands {
const policiesServer = await getPolicyServerOBJs(
assetsForPolicy,
assetAlgo,
- this.signer,
+ paymentSigner,
this.oceanNodeUrl,
);
@@ -803,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(
@@ -840,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(
@@ -886,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(
@@ -904,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(", "),
@@ -943,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));
@@ -952,7 +1293,7 @@ export class Commands {
paymentToken,
computeEnv.consumerAddress,
await unitsToAmount(
- this.signer,
+ paymentSigner,
paymentToken,
parsedProviderInitializeComputeJob.payment.amount,
),
@@ -979,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 =
@@ -997,7 +1338,7 @@ export class Commands {
if (available < requiredUnits) {
const shortfallUnits = requiredUnits - available;
const shortfall = await unitsToAmount(
- this.signer,
+ paymentSigner,
paymentToken,
shortfallUnits.toString(),
);
@@ -1009,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),
@@ -1022,7 +1363,7 @@ export class Commands {
}
if (available < requiredUnits) {
const needed = await unitsToAmount(
- this.signer,
+ paymentSigner,
paymentToken,
requiredUnits.toString(),
);
@@ -1050,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,
);
@@ -1113,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
@@ -1134,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[]) {
@@ -1366,9 +1708,187 @@ 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));
}
+ /**
+ * Discover compute providers across the network that can run a job needing the given
+ * resources, via `ProviderInstance.findComputeProviders` (a P2P/DHT lookup with no HTTP
+ * equivalent). "both" runs a free and a paid search and merges the results, tagging each
+ * environment's tier. Results are filtered/priced/ordered by the helpers and printed; an
+ * empty tier prints a per-dimension breakdown instead of an opaque empty list.
+ */
+ public async searchComputeResources(params: ResourceSearchParams) {
+ await ensureP2PReady();
+ const seed = getSearchSeedNode();
+ const resourceList = params.resources
+ .map((d) => `${d.resource}=${d.value}`)
+ .join(", ");
+
+ const tiers: ("free" | "paid")[] =
+ params.mode === "both" ? ["free", "paid"] : [params.mode];
+
+ console.log(
+ chalk.cyan(
+ `Searching the network for compute providers (seed ${seed.slice(0, 24)}…).`,
+ ),
+ );
+ console.log(
+ chalk.gray(
+ `Requested: ${resourceList}. Tiers to search: ${
+ tiers.length > 1 ? `${tiers.join(" and ")} (concurrently)` : tiers[0]
+ }. Each is a DHT lookup and can take up to ~1 minute — please wait, do not type ` +
+ `until results appear.`,
+ ),
+ );
+ console.log(
+ chalk.yellow(
+ "Tip: each result starts with a ready-to-run setNodeEnv | command — copy-paste " +
+ "that whole line to select the node and compute env for your next compute command.",
+ ),
+ );
+
+ const request = {
+ resources: params.resources.map((d) => ({
+ resource: d.resource,
+ value: d.value,
+ })),
+ models: params.models,
+ };
+
+ // A DHT lookup with no answering peers never completes on its own, so bound each tier with
+ // a timeout instead of hanging forever. `findComputeProviders` accepts the signal and aborts
+ // the underlying query. Override with SEARCH_TIMEOUT_MS.
+ const timeout = Number(process.env.SEARCH_TIMEOUT_MS) || SEARCH_TIMEOUT_MS;
+
+ process.stdout.write(
+ chalk.cyan(
+ `\nSearching ${tiers.map((t) => t.toUpperCase()).join(" + ")}...`,
+ ),
+ );
+ // One shared heartbeat while any tier is in flight — the tiers run concurrently, so a
+ // per-tier inline spinner would interleave into noise. Each tier prints its own completion
+ // line (on a fresh line) as it finishes.
+ const heartbeat = setInterval(() => {
+ process.stdout.write(chalk.gray("."));
+ }, 3000);
+
+ interface TierOutcome {
+ tier: "free" | "paid";
+ rows: ProviderEnvRow[];
+ dimensions?: ComputeSearchDimensionResult[];
+ }
+
+ // Run every tier concurrently: they are independent DHT lookups on the same libp2p node, so
+ // "both" finishes in ~one tier's time instead of the sum. Promise.all preserves tier order.
+ const outcomes = await Promise.all(
+ tiers.map(async (tier): Promise => {
+ const started = Date.now();
+ try {
+ const result = await ProviderInstance.findComputeProviders(seed, {
+ free: tier === "free",
+ ...request,
+ signal: AbortSignal.timeout(timeout),
+ });
+ const secs = ((Date.now() - started) / 1000).toFixed(0);
+ const tierRows = buildRows(result.providers, tier, params);
+ console.log(
+ chalk.cyan(
+ `\n ${tier.toUpperCase()} done in ${secs}s (${result.providers.length} provider(s), ${tierRows.length} match(es))`,
+ ),
+ );
+ return {
+ tier,
+ rows: tierRows,
+ dimensions: tierRows.length === 0 ? result.dimensions : undefined,
+ };
+ } catch (error) {
+ const secs = ((Date.now() - started) / 1000).toFixed(0);
+ const timedOut =
+ error?.name === "TimeoutError" || error?.name === "AbortError";
+ console.log(
+ chalk.yellow(
+ `\n ${tier.toUpperCase()} ${
+ timedOut
+ ? `timed out after ${secs}s (no providers answered the DHT lookup)`
+ : `failed after ${secs}s: ${error?.message ?? error}`
+ }`,
+ ),
+ );
+ // A failed/timed-out tier just contributes no rows; the other tier still stands.
+ return { tier, rows: [] };
+ }
+ }),
+ );
+ clearInterval(heartbeat);
+
+ // Aggregate in tier order (Promise.all kept it) for deterministic output.
+ let rows: ProviderEnvRow[] = [];
+ const emptyTiers: {
+ tier: "free" | "paid";
+ dimensions: ComputeSearchDimensionResult[];
+ }[] = [];
+ for (const outcome of outcomes) {
+ rows = rows.concat(outcome.rows);
+ if (outcome.rows.length === 0 && outcome.dimensions) {
+ emptyTiers.push({ tier: outcome.tier, dimensions: outcome.dimensions });
+ }
+ }
+
+ // Drop everything that doesn't match the request (wrong chain, unaccepted token, or
+ // resources that fall short — the DHT returns all of a matching node's envs), then apply the
+ // optional price cap, then order.
+ const built = rows.length;
+ rows = filterMatches(rows, params);
+ const droppedUnmatched = built - rows.length;
+
+ const beforeCap = rows.length;
+ rows = applyMaxPrice(rows, params.maxPrice);
+ const droppedByCap = beforeCap - rows.length;
+
+ rows = orderRows(rows, params.orderBy);
+
+ if (droppedUnmatched > 0) {
+ console.log(
+ chalk.gray(
+ `\nFiltered out ${droppedUnmatched} non-matching result(s) (wrong chain, unaccepted token, or insufficient resources).`,
+ ),
+ );
+ }
+ if (droppedByCap > 0) {
+ console.log(
+ chalk.gray(`Filtered out ${droppedByCap} result(s) over --max-price.`),
+ );
+ }
+
+ if (rows.length === 0) {
+ console.log(chalk.yellow("\nNo matching compute providers found."));
+ // Per-tier DHT breakdown when a tier found nothing at all; otherwise everything the
+ // search returned was filtered out by the criteria above.
+ for (const { tier, dimensions } of emptyTiers) {
+ printDimensionDiagnostics(tier, dimensions);
+ }
+ if (emptyTiers.length === 0 && built > 0) {
+ console.log(
+ chalk.yellow(
+ `All ${built} result(s) the search returned were filtered out by your chain/token/price/resource criteria — try relaxing them.`,
+ ),
+ );
+ }
+ return;
+ }
+
+ printRows(rows, params);
+ }
+
public async computeStreamableLogs(args: string[]) {
const jobId = args[0];
const controller = new AbortController();
@@ -2213,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();
@@ -2303,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();
@@ -2415,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,
@@ -2434,7 +2971,7 @@ export class Commands {
];
const tokenContract = new ethers.Contract(
- config?.Ocean,
+ tokenAddress,
minAbi,
this.signer,
);
@@ -2480,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 {
@@ -2511,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);
@@ -2537,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,
@@ -2595,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);
@@ -2645,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(
@@ -2705,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();
@@ -2905,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 6cfd2fc..bfe1907 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,8 +4,9 @@ import { Command, CommanderError } from "commander";
import chalk from "chalk";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "readline/promises";
-import { createCLI } from "./cli.js";
+import { createCLI, formatGroupedHelp } from "./cli.js";
import { stopP2P } from "./nodeConnection.js";
+import { destroyProviders } from "./rpcRegistry.js";
let program: Command;
const supportedCommands: string[] = [];
@@ -115,6 +116,40 @@ async function runTokens(tokens: string[]): Promise {
const PROMPT =
"Enter command ('exit' | 'quit' | ESC or CTRL-C to terminate):\n";
+/**
+ * Discard whatever the user typed while a command was running and no prompt was visible.
+ *
+ * A command can run for a long time (a DHT search, a chain call, an interactive enquirer
+ * wizard), during which the REPL's readline is paused and the terminal buffers every
+ * keystroke. Left in place, that blind type-ahead is delivered to the REPL the moment it
+ * resumes — so an impatient key-mash, an up-arrow+enter recalling the previous command, or a
+ * line an enquirer wizard fed back re-runs a command the user never meant to submit (e.g.
+ * re-launching the search wizard). Draining first makes every command one the user actually
+ * saw the prompt for and typed.
+ *
+ * Must be async: on a TTY, buffered input is delivered through asynchronous `data` events, not
+ * synchronous `read()` — a `read()` loop returns null and drains nothing. So we briefly put
+ * the stream in flowing mode, swallow whatever `data` arrives, then pause again.
+ *
+ * TTY-only: piped stdin (tests, scripts) is never drained, so scripted input is untouched.
+ */
+const INPUT_FLUSH_MS = 80;
+async function discardBufferedInput(): Promise {
+ if (!input.isTTY) return;
+ await new Promise((resolve) => {
+ const onData = (): void => {
+ /* swallow buffered keystrokes */
+ };
+ input.on("data", onData);
+ input.resume();
+ setTimeout(() => {
+ input.pause();
+ input.off("data", onData);
+ resolve();
+ }, INPUT_FLUSH_MS);
+ });
+}
+
/**
* Tab-completion for the command name (the first token only). readline completes
* to the longest common prefix of the matches, or lists them when there is more
@@ -130,46 +165,88 @@ function completer(line: string): [string[], string] {
/**
* Read commands from stdin until the user exits or input is exhausted (EOF).
*
- * A single persistent readline interface is consumed via its async iterator so
- * that backpressure is respected and no buffered lines are dropped — creating a
- * fresh interface per prompt silently discards piped input beyond the first
- * line. The interface is paused around command execution so it never competes
- * for stdin with an interface a command opens itself (e.g. the payment
- * confirmation prompt in cli.ts).
+ * Two shapes, because an interactive terminal and a pipe have opposite needs:
+ *
+ * - Interactive (TTY): a fresh readline interface per prompt, fully closed around command
+ * execution. This matters because a command may open its OWN stdin reader — notably the
+ * enquirer search wizard — and a persistent readline keeps its `data`/`keypress` listeners
+ * attached even when paused, so it competes for keystrokes and captures blind type-ahead (or
+ * a line the wizard fed back) into its queue, which then replays as a command. Closing it
+ * first gives the command exclusive stdin; the async flush after runs with no reader
+ * attached, so anything typed blind is actually discarded instead of re-run.
+ * - Piped (scripts/tests): a single persistent interface consumed via its async iterator, so
+ * backpressure is respected and no buffered line is dropped — recreating per prompt would
+ * silently discard piped input beyond the first line. No drain (there is no blind wait).
*/
async function runLoop(): Promise {
- const rl = createInterface({ input, output, completer });
+ if (input.isTTY) {
+ await runInteractiveLoop();
+ } else {
+ await runPipedLoop();
+ }
+}
- // On a TTY, let the Escape key exit the REPL (Ctrl-C already terminates via
- // SIGINT; `exit`/`quit`/`\q`/EOF still work). readline already emits keypress
- // events on the input stream in terminal mode, so a listener is enough — no
- // raw-mode juggling. Guarded by isTTY so piped stdin (tests, scripts) is
- // unaffected.
- const onKeypress = (_str: string, key?: { name?: string }): void => {
- if (key?.name === "escape") {
- output.write("\n");
- rl.close();
- }
- };
- if (input.isTTY) input.on("keypress", onKeypress);
+/** REPL for an interactive terminal. See runLoop for why the interface is recreated per line. */
+async function runInteractiveLoop(): Promise {
+ // Drop any type-ahead buffered while the initial argv command ran (before any readline
+ // existed), so a blind key-mash during a slow first command doesn't replay as a command.
+ await discardBufferedInput();
+
+ // Command history is carried across prompts even though the interface is recreated each
+ // time: readline references (does not copy) this array as its history and mutates it in
+ // place on each committed line, so passing the same array back preserves ↑/↓ recall.
+ let history: string[] = [];
+ for (;;) {
+ const rl = createInterface({ input, output, completer, history });
+ // Escape exits the REPL (Ctrl-C terminates via SIGINT; `exit`/`quit`/`\q`/EOF also work).
+ const onKeypress = (_str: string, key?: { name?: string }): void => {
+ if (key?.name === "escape") {
+ output.write("\n");
+ rl.close();
+ }
+ };
+ input.on("keypress", onKeypress);
+ rl.setPrompt(PROMPT);
+ rl.prompt();
+
+ // Resolve on the first line, or null when the interface closes (EOF or Escape).
+ const rawLine = await new Promise((resolve) => {
+ rl.once("line", (l) => resolve(l));
+ rl.once("close", () => resolve(null));
+ });
+ // Re-capture the history array in case readline swapped in a new one (it normally mutates
+ // the passed array in place, but this keeps recall correct regardless).
+ history = (rl as unknown as { history?: string[] }).history ?? history;
+ input.off("keypress", onKeypress);
+ rl.close();
+
+ if (rawLine === null) break; // EOF or Escape
+ const line = rawLine.trim();
+ if (line === "quit" || line === "exit" || line === "\\q") break;
+ if (line === "") continue;
+
+ const tokens = stripNpmPrefix(tokenize(line));
+ // The interface is closed, so a command's own prompt (the enquirer wizard) and the flush
+ // below both get exclusive, un-intercepted stdin.
+ await runTokens(tokens);
+ await discardBufferedInput();
+ }
+}
+
+/** REPL for piped stdin (scripts/tests). Persistent interface; see runLoop. */
+async function runPipedLoop(): Promise {
+ const rl = createInterface({ input, output, completer });
rl.setPrompt(PROMPT);
rl.prompt();
-
try {
for await (const rawLine of rl) {
const line = rawLine.trim();
-
- if (line === "quit" || line === "exit" || line === "\\q") {
- break;
- }
-
- // Empty input: re-prompt instead of busy-waiting or dropping the session.
+ if (line === "quit" || line === "exit" || line === "\\q") break;
if (line === "") {
rl.prompt();
continue;
}
-
const tokens = stripNpmPrefix(tokenize(line));
rl.pause();
await runTokens(tokens);
@@ -177,7 +254,6 @@ async function runLoop(): Promise {
rl.prompt();
}
} finally {
- if (input.isTTY) input.off("keypress", onKeypress);
rl.close();
}
}
@@ -224,7 +300,7 @@ async function main(): Promise {
process.argv.includes("-h") ||
isBareHelp
) {
- program.outputHelp();
+ console.log(formatGroupedHelp(program));
return;
}
if (process.argv.includes("--version") || process.argv.includes("-V")) {
@@ -249,7 +325,7 @@ async function main(): Promise {
if (initialTokens.length > 0) {
await runTokens(initialTokens);
} else {
- console.log(program.helpInformation());
+ console.log(formatGroupedHelp(program));
}
// Then loop on stdin until the user exits or input is exhausted.
@@ -260,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 {
@@ -270,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/nodeConnection.ts b/src/nodeConnection.ts
index afe31aa..2ffde3d 100644
--- a/src/nodeConnection.ts
+++ b/src/nodeConnection.ts
@@ -182,6 +182,43 @@ export function hasNode(): boolean {
return getCurrentNodeUrl().length > 0;
}
+/**
+ * The compute environment remembered by `setNodeEnv`, so a following `startCompute` /
+ * `startFreeCompute` can default its `--env` to it — the user pastes the node+env token from a
+ * search result once instead of repeating the env id on every compute command. Like NODE_URL it
+ * lives in an env var so it survives across REPL commands and is read fresh each time; "" = none.
+ */
+export function getCurrentEnvId(): string {
+ return process.env.COMPUTE_ENV_ID || "";
+}
+
+/** Remember the compute environment id for subsequent compute commands (see getCurrentEnvId). */
+export function setCurrentEnvId(envId: string): void {
+ process.env.COMPUTE_ENV_ID = envId;
+}
+
+/**
+ * Forget any remembered compute environment. Called when the active node changes, since an env id
+ * belongs to a specific node and is meaningless (or wrong) once a different node is selected.
+ */
+export function clearCurrentEnvId(): void {
+ delete process.env.COMPUTE_ENV_ID;
+}
+
+/**
+ * A P2P node handle to seed a `findComputeProviders` DHT search from. The search is
+ * network-wide — it consults this peer's DHT — so the seed only needs to be *some*
+ * reachable P2P node, not the node the user ultimately wants to compute on. Prefer the
+ * active node when it is a P2P URI (so a local Barge peer is used directly), otherwise
+ * fall back to an Ocean bootstrap peer so the search works even for an HTTP-configured
+ * user who has no P2P node selected. Callers must `ensureP2PReady()` first.
+ */
+export function getSearchSeedNode(): string {
+ const active = getCurrentNodeUrl();
+ if (active && isP2pUri(active)) return active;
+ return OCEAN_BOOTSTRAP_PEERS[0];
+}
+
/**
* Health-check a candidate node without touching any existing state. Over HTTP this is
* a plain status request; over P2P the on-demand dial *is* the reachability check.
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/searchResourcesFlow.ts b/src/searchResourcesFlow.ts
index 6ad7e73..507a721 100644
--- a/src/searchResourcesFlow.ts
+++ b/src/searchResourcesFlow.ts
@@ -12,6 +12,7 @@ import figlet from "figlet";
import {
ResourceSearchParams,
ResourceDimension,
+ ChainFilter,
SearchMode,
SearchOrderBy,
} from "./searchResourcesHelpers.js";
@@ -135,52 +136,90 @@ export async function interactiveResourceSearch(
},
});
- let chainId: number | undefined;
- let token: string | undefined;
+ let chains: ChainFilter[] | undefined;
let maxPrice: number | undefined;
let durationSeconds: number | undefined;
// 4-6. Paid filters.
if (mode === "paid" || mode === "both") {
- const chainChoices = [
+ const OTHER = "Other (type chainId(s))";
+ // Map the human-readable choice labels back to chainIds.
+ const known: { name: string; id: number }[] = [
...(defaultChainId
- ? [{ name: `Current RPC chain (${defaultChainId})`, value: defaultChainId }]
+ ? [{ name: `Current RPC chain (${defaultChainId})`, id: defaultChainId }]
: []),
- { name: "Ethereum (1)", value: 1 },
- { name: "Polygon (137)", value: 137 },
- { name: "Oasis Sapphire (23294)", value: 23294 },
- { name: "Other (type a chainId)", value: -1 },
+ { name: "Ethereum (1)", id: 1 },
+ { name: "Polygon (137)", id: 137 },
+ { name: "Base (8453)", id: 8453 },
+ { name: "Oasis Sapphire (23294)", id: 23294 },
];
- const { chain } = await prompt<{ chain: number }>({
- type: "select",
- name: "chain",
- message: chalk.green("Which chain should pricing use?\n"),
- choices: chainChoices,
- result(value: string) {
- return this.choices.find((choice) => choice.name === value).value;
- },
- });
- if (chain === -1) {
- const { manual } = await prompt<{ manual: string }>({
+ const { pickedChains } = await prompt<{ pickedChains: string[] }>({
+ type: "multiselect",
+ name: "pickedChains",
+ message: chalk.green(
+ "Which chain(s) should pricing use? (space to toggle, enter to confirm)\n",
+ ),
+ indicator: { on: "◉", off: "◯" },
+ choices: [...known.map((k) => ({ name: k.name })), { name: OTHER }],
+ } as never);
+
+ const chainIds: number[] = [];
+ for (const name of pickedChains) {
+ if (name === OTHER) continue;
+ const hit = known.find((k) => k.name === name);
+ if (hit) chainIds.push(hit.id);
+ }
+
+ // Custom chainIds — allow adding several.
+ if (pickedChains.includes(OTHER)) {
+ let addMore = true;
+ while (addMore) {
+ const { manual } = await prompt<{ manual: string }>({
+ type: "input",
+ name: "manual",
+ message: chalk.green("Enter a chainId:\n"),
+ validate: (v: string) =>
+ (/^\d+$/.test(v.trim()) && Number(v) > 0) ||
+ "Enter a positive integer chainId.",
+ });
+ chainIds.push(Number(manual));
+ const { again } = await prompt<{ again: boolean }>({
+ type: "toggle",
+ name: "again",
+ message: chalk.green("Add another chain?\n"),
+ enabled: "Yes",
+ disabled: "No",
+ });
+ addMore = again;
+ }
+ }
+
+ // Deduplicate; fall back to the RPC chain if nothing was chosen.
+ let uniqueIds = [...new Set(chainIds)];
+ if (uniqueIds.length === 0) {
+ if (defaultChainId === undefined) {
+ throw new Error("Select at least one chain to price against.");
+ }
+ uniqueIds = [defaultChainId];
+ }
+
+ // Per chain, ask which payment tokens to restrict to (blank = any token on that chain).
+ chains = [];
+ for (const chainId of uniqueIds) {
+ const { tokensIn } = await prompt<{ tokensIn: string }>({
type: "input",
- name: "manual",
- message: chalk.green("Enter the chainId:\n"),
- validate: (v: string) => Number.isInteger(Number(v)) || "Enter an integer chainId.",
+ name: "tokensIn",
+ message: chalk.green(
+ `Restrict chain ${chainId} to specific payment-token address(es)? (comma-separated, blank for any)\n`,
+ ),
});
- chainId = Number(manual);
- } else {
- chainId = chain;
+ const tokens = tokensIn
+ .split(",")
+ .map((t) => t.trim())
+ .filter(Boolean);
+ chains.push({ chainId, tokens: tokens.length ? tokens : undefined });
}
- const { tokenIn } = await prompt<{ tokenIn: string }>({
- type: "input",
- name: "tokenIn",
- message: chalk.green(
- "Restrict to a payment-token address? (leave blank for any)\n",
- ),
- });
- if (tokenIn && tokenIn.trim()) token = tokenIn.trim();
-
const { priceIn } = await prompt<{ priceIn: string }>({
type: "input",
name: "priceIn",
@@ -224,8 +263,7 @@ export async function interactiveResourceSearch(
resources,
models,
mode,
- chainId,
- token,
+ chains,
maxPrice,
durationSeconds,
orderBy,
diff --git a/src/searchResourcesHelpers.ts b/src/searchResourcesHelpers.ts
index e2bb1e5..f2f7ae6 100644
--- a/src/searchResourcesHelpers.ts
+++ b/src/searchResourcesHelpers.ts
@@ -11,6 +11,7 @@ import {
ComputeEnvironment,
ComputeProviderMatch,
ComputeResource,
+ ComputeSearchDimensionResult,
} from "@oceanprotocol/lib";
import { estimateServiceCost } from "./serviceHelpers.js";
@@ -26,29 +27,45 @@ export interface ResourceDimension {
value: number;
}
+// One chain to price/pay against, with an optional set of payment-token addresses to restrict
+// to on THAT chain. An empty/absent `tokens` means "any token the env accepts on this chain".
+export interface ChainFilter {
+ chainId: number;
+ tokens?: string[];
+}
+
export interface ResourceSearchParams {
// One entry per requested resource dimension (AND-ed together by the DHT lookup).
resources: ResourceDimension[];
// Optional per-resource verification qualifier, e.g. { gpu: "A100" }.
models?: Record;
mode: SearchMode;
- // Paid/both only. Defaults to the RPC chainId when the user does not override it.
- chainId?: number;
- token?: string; // optional payment-token filter
+ // Paid/both only. One or more chains, each with an optional per-chain token filter. Pricing
+ // is computed across all of them and the cheapest (chain, token) wins. Defaults to the RPC
+ // chainId (no token filter) when the user does not override it.
+ chains?: ChainFilter[];
maxPrice?: number; // optional cap on estimated cost (human units)
durationSeconds?: number; // assumed job duration for cost estimate/ordering
orderBy: SearchOrderBy;
}
+// Payment tokens an env accepts on one requested chain (already narrowed by that chain's token
+// filter, if any), carried on a row for display.
+export interface ChainTokens {
+ chainId: number;
+ tokens: string[];
+}
+
// A single (provider, environment) pairing, decorated with the values we order/print by.
export interface ProviderEnvRow {
nodeId: string;
multiaddrs: string[];
env: ComputeEnvironment;
tier: "free" | "paid";
- estCost: number | null; // estimated cost in human units (paid), or null when unpriced
+ estCost: number | null; // cheapest estimated cost across requested chains/tokens, or null
token?: string; // the token estCost was computed for
- acceptedTokens: string[]; // payment-token addresses the env accepts on the requested chain
+ chainId?: number; // the chain estCost was computed on
+ acceptedByChain: ChainTokens[]; // per requested chain, the (filtered) tokens the env accepts
pricedOnChains: string[]; // every chainId the env advertises pricing for
freeCapacity: number; // sum of available capacity for requested dims (free tier)
availableResources: number; // sum of (max - inUse) for requested dims
@@ -168,8 +185,7 @@ export function buildParamsFromFlags(
resources,
models,
mode,
- chainId: flags.chain ? Number(flags.chain) : defaultChainId,
- token: flags.token,
+ chains: buildChainsFromFlags(flags, defaultChainId),
maxPrice: flags.maxPrice ? toPositiveNumber("--max-price", flags.maxPrice) : undefined,
durationSeconds: flags.duration
? toPositiveNumber("--duration", flags.duration)
@@ -178,6 +194,38 @@ export function buildParamsFromFlags(
};
}
+// Split a comma-separated list into trimmed, non-empty entries.
+function splitList(raw: string | undefined): string[] {
+ if (!raw) return [];
+ return raw
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+// Build the chain filters from flags. `--chain` is a comma-separated list of chainIds (each an
+// integer); `--token` is a comma-separated list of token addresses applied as the filter on
+// EVERY requested chain (per-chain token sets are only expressible through the wizard). With no
+// `--chain`, fall back to the RPC chain. The `--token` filter still applies to that fallback.
+function buildChainsFromFlags(
+ flags: SearchFlags,
+ defaultChainId: number,
+): ChainFilter[] {
+ const tokens = splitList(flags.token);
+ const chainIds = splitList(flags.chain).map((c) => {
+ const n = Number(c);
+ if (!Number.isInteger(n)) {
+ throw new Error(`--chain "${c}" is not an integer chainId`);
+ }
+ return n;
+ });
+ const ids = chainIds.length > 0 ? chainIds : [defaultChainId];
+ return ids.map((chainId) => ({
+ chainId,
+ tokens: tokens.length > 0 ? tokens : undefined,
+ }));
+}
+
// ---------------------------------------------------------------------------
// Resource matching (mirrors the lib's verification predicate, for display)
// ---------------------------------------------------------------------------
@@ -215,56 +263,57 @@ function sumFor(
// Row building + ordering
// ---------------------------------------------------------------------------
-// Cost of an env for the requested dims, in human units, or null when it cannot be priced
-// in the requested/each accepted token. When no token is fixed, returns the cheapest.
-function priceEnv(
+// The payment tokens an env accepts on one chain, narrowed to that chain's token filter when it
+// has one (case-insensitive). Empty when the env does not price on the chain, or none match.
+function acceptedTokensOnChain(
+ env: ComputeEnvironment,
+ chain: ChainFilter,
+): string[] {
+ const onChain = (env.fees?.[String(chain.chainId)] ?? []).map((s) => s.feeToken);
+ if (!chain.tokens || chain.tokens.length === 0) return onChain;
+ const want = new Set(chain.tokens.map((t) => t.toLowerCase()));
+ return onChain.filter((t) => want.has(t.toLowerCase()));
+}
+
+// The cheapest priceable (token, cost) for an env on ONE chain, respecting that chain's token
+// filter — or null when the env prices on the chain with no allowed token.
+function priceEnvOnChain(
env: ComputeEnvironment,
+ chain: ChainFilter,
params: ResourceSearchParams,
-): { cost: number | null; token?: string } {
- if (params.chainId === undefined) return { cost: null };
+): { cost: number; token: string } | null {
const duration = params.durationSeconds ?? 3600;
const amounts = params.resources.map((d) => ({ id: d.resource, amount: d.value }));
-
- const schedules = env.fees?.[String(params.chainId)] ?? [];
- const tokens = params.token
- ? [params.token]
- : schedules.map((s) => s.feeToken);
-
let best: { cost: number; token: string } | null = null;
- for (const token of tokens) {
- const cost = estimateServiceCost(env, params.chainId, token, amounts, duration);
+ for (const token of acceptedTokensOnChain(env, chain)) {
+ const cost = estimateServiceCost(env, chain.chainId, token, amounts, duration);
if (cost === null) continue;
if (best === null || cost < best.cost) best = { cost, token };
}
- return best ? { cost: best.cost, token: best.token } : { cost: null };
+ return best;
}
// Flatten one search's provider matches into decorated rows for the given tier.
+//
+// Fan-out: a PAID env is emitted once PER requested chain it can be priced on (each row carrying
+// that chain's cheapest token/cost), so the same env can be compared across chains side by side.
+// An env that prices on none of the requested chains still yields a single row (estCost null) so
+// it surfaces with the "prices elsewhere" hint. Free envs are one row each.
export function buildRows(
matches: ComputeProviderMatch[],
tier: "free" | "paid",
params: ResourceSearchParams,
): ProviderEnvRow[] {
const rows: ProviderEnvRow[] = [];
+ const chains = params.chains ?? [];
for (const match of matches) {
const multiaddrs = (match.node.multiaddress ?? []).map((m) => m.toString());
for (const env of match.environments) {
- const { cost, token } = tier === "paid" ? priceEnv(env, params) : { cost: null, token: undefined };
- const chainKey = params.chainId !== undefined ? String(params.chainId) : undefined;
- const acceptedTokens =
- tier === "paid" && chainKey
- ? (env.fees?.[chainKey] ?? []).map((s) => s.feeToken)
- : [];
- const pricedOnChains = tier === "paid" ? Object.keys(env.fees ?? {}) : [];
- rows.push({
+ const shared = {
nodeId: match.node.nodeId,
multiaddrs,
env,
tier,
- estCost: cost,
- token,
- acceptedTokens,
- pricedOnChains,
freeCapacity: sumFor(env, "free", params.resources, (r) => r.max ?? 0),
availableResources: sumFor(
env,
@@ -274,25 +323,90 @@ export function buildRows(
),
runningJobs: env.runningJobs ?? 0,
queuedJobs: env.queuedJobs ?? 0,
+ };
+
+ if (tier === "free") {
+ rows.push({
+ ...shared,
+ estCost: null,
+ token: undefined,
+ chainId: undefined,
+ acceptedByChain: [],
+ pricedOnChains: [],
+ });
+ continue;
+ }
+
+ const pricedOnChains = Object.keys(env.fees ?? {});
+ // One row per requested chain the env can actually be priced on.
+ const pricedRows = chains.flatMap((chain) => {
+ const priced = priceEnvOnChain(env, chain, params);
+ if (!priced) return [];
+ return [
+ {
+ ...shared,
+ estCost: priced.cost,
+ token: priced.token,
+ chainId: chain.chainId,
+ acceptedByChain: [
+ { chainId: chain.chainId, tokens: acceptedTokensOnChain(env, chain) },
+ ] as ChainTokens[],
+ pricedOnChains,
+ },
+ ];
});
+
+ if (pricedRows.length > 0) {
+ rows.push(...pricedRows);
+ } else {
+ // Not priceable on any requested chain: a single row that surfaces the env anyway.
+ rows.push({
+ ...shared,
+ estCost: null,
+ token: undefined,
+ chainId: undefined,
+ acceptedByChain: [],
+ pricedOnChains,
+ });
+ }
}
}
return rows;
}
-// When the user pins a specific payment token, keep only paid envs that actually accept it
-// on the requested chain. Free rows have no token concept and are left untouched.
-export function filterByToken(
+// Does an env actually satisfy every requested resource dimension in its tier? The DHT lookup
+// returns *all* of a matching node's environments — including ones whose resources fall short of
+// the request (max < need) — so this is what drops those non-matching envs.
+function rowMeetsResources(
+ row: ProviderEnvRow,
+ dims: ResourceDimension[],
+): boolean {
+ const resources = tierResources(row.env, row.tier);
+ return dims.every((dim) => {
+ const have = resources
+ .filter((r) => resourceMatches(r, dim.resource))
+ .reduce((s, r) => s + (r.max ?? 0), 0);
+ return have >= dim.value;
+ });
+}
+
+// Keep only rows that genuinely match the request, dropping everything that does not:
+// - any env whose resources do not meet the requested amounts (both tiers);
+// - any PAID env that could not be priced on one of the requested chains with an allowed token
+// (wrong chain, or the specified token(s) are not accepted) — its estCost is null.
+// A "both" search's free rows are still kept regardless of chain/token, since those are paid-only
+// concepts. When no chains are requested (defensive; paid always has at least the RPC chain),
+// the chain/token check is skipped.
+export function filterMatches(
rows: ProviderEnvRow[],
- token?: string,
+ params: ResourceSearchParams,
): ProviderEnvRow[] {
- if (!token) return rows;
- const want = token.toLowerCase();
- return rows.filter(
- (row) =>
- row.tier !== "paid" ||
- row.acceptedTokens.some((t) => t.toLowerCase() === want),
- );
+ const hasChains = (params.chains?.length ?? 0) > 0;
+ return rows.filter((row) => {
+ if (!rowMeetsResources(row, params.resources)) return false;
+ if (row.tier === "paid" && hasChains && row.estCost === null) return false;
+ return true;
+ });
}
// Apply the optional maxPrice cap. Only meaningful for priced rows.
@@ -342,10 +456,35 @@ export function orderRows(
// so tests and scripts can assert on results without parsing the pretty block.
export function providerSummaryLine(row: ProviderEnvRow): string {
const price =
- row.estCost !== null ? `${row.estCost}${row.token ? ` ${row.token}` : ""}` : "n/a";
+ row.estCost !== null
+ ? `${row.estCost}${row.token ? ` ${row.token}` : ""}${
+ row.chainId !== undefined ? `@${row.chainId}` : ""
+ }`
+ : "n/a";
return `PROVIDER node=${row.nodeId} env=${row.env.id} tier=${row.tier} price=${price} freeCapacity=${row.freeCapacity} available=${row.availableResources} running=${row.runningJobs} queued=${row.queuedJobs}`;
}
+// When a search tier returns nothing, explain *why* per requested dimension instead of an
+// opaque empty list: the bucket the lookup actually used and how many providers announced it
+// (before verification/intersection). A dimension with zero announcers is the culprit; one
+// with announcers that still yields no matches was dropped by verification or intersection.
+export function printDimensionDiagnostics(
+ tier: "free" | "paid",
+ dimensions: ComputeSearchDimensionResult[] | undefined,
+): void {
+ console.log(
+ chalk.yellow(`\nNo ${tier} providers matched. Per-resource breakdown:`),
+ );
+ for (const dim of dimensions ?? []) {
+ const count = dim.providerIds?.length ?? 0;
+ const partial = dim.partial ? ` (partial: ${dim.error ?? "lookup ended early"})` : "";
+ console.log(
+ ` ${dim.resource}: requested ${dim.value}, searched bucket ${dim.bucket}, ` +
+ `${count} announcer(s)${partial}`,
+ );
+ }
+}
+
// Generic resource `kind`s that only say whether a resource is a divisible pool
// (cpu/ram/disk) — no use to a reader, so they are dropped from the description. A
// meaningful kind (e.g. a GPU model) or an explicit `description` is still shown.
@@ -386,40 +525,37 @@ export function printRows(
symbols?: Map,
): void {
if (rows.length === 0) return;
- console.log(chalk.cyan(`\nFound ${rows.length} matching environment(s):\n`));
+ // Rows are (environment × chain) matches, so a paid env priced on several requested chains
+ // appears once per chain — count matches, not distinct environments.
+ console.log(chalk.cyan(`\nFound ${rows.length} match(es):\n`));
for (const row of rows) {
+ // First line is a ready-to-run command and NOTHING else (unstyled, no trailing tag) so the
+ // whole line can be copy-pasted verbatim — a trailing token would become an extra argument.
+ // The tier tag goes on the following line instead.
+ console.log(`setNodeEnv ${row.nodeId}|${row.env.id}`);
console.log(
- `${chalk.bold(row.nodeId)} ${chalk.gray(`[${row.tier}]`)} env ${chalk.green(row.env.id)}`,
+ ` ${chalk.gray(`[${row.tier}]`)} ${describeRowResources(row, params)}`,
);
- console.log(` ${describeRowResources(row, params)}`);
if (row.tier === "paid") {
if (row.estCost !== null) {
+ // This row is one chain; the cost is the cheapest token on it.
console.log(
- ` estimated cost: ${row.estCost} ${tokenDisplay(row.token ?? "", symbols)}`,
+ ` estimated cost: ${row.estCost} ${tokenDisplay(
+ row.token ?? "",
+ symbols,
+ )} on chain ${row.chainId}`,
);
}
- if (row.acceptedTokens.length) {
- // Show every token the env accepts on this chain (address + symbol) so the
- // user knows their payment options — required when they searched "all tokens".
+ // The (filtered) tokens the env accepts on this row's chain, so the user sees their full
+ // payment options — not just the cheapest one named in the cost line above. Displayed rows
+ // are always priced on a requested chain (non-matching rows were filtered out upstream).
+ const onChain = row.acceptedByChain.find((c) => c.chainId === row.chainId);
+ if (onChain && onChain.tokens.length) {
console.log(
- ` accepted tokens (chain ${params.chainId}): ${row.acceptedTokens
+ ` accepted tokens (chain ${onChain.chainId}): ${onChain.tokens
.map((t) => tokenDisplay(t, symbols))
.join(", ")}`,
);
- } else {
- // No fee schedule for the requested chain: point at the chains it does price on.
- const others = row.pricedOnChains.filter(
- (c) => c !== String(params.chainId),
- );
- console.log(
- others.length
- ? chalk.yellow(
- ` no pricing on chain ${params.chainId}; this env prices on chain(s): ${others.join(
- ", ",
- )} — re-run with --chain `,
- )
- : chalk.yellow(" no pricing information advertised"),
- );
}
}
console.log(
@@ -433,7 +569,7 @@ export function printRows(
}
console.log(
chalk.yellow(
- "Tip: select a provider with setNode then run compute with startCompute --env ...",
+ "Tip: copy-paste a result's first line (the setNodeEnv | command) to select both at once, then run startCompute ... (the env is remembered, no --env needed).",
),
);
}
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/searchResources.unit.test.ts b/test/searchResources.unit.test.ts
index 8ab22c4..81a3cd3 100644
--- a/test/searchResources.unit.test.ts
+++ b/test/searchResources.unit.test.ts
@@ -9,7 +9,7 @@ import {
hasSearchFlags,
buildRows,
applyMaxPrice,
- filterByToken,
+ filterMatches,
orderRows,
providerSummaryLine,
describeRowResources,
@@ -68,10 +68,21 @@ describe("searchResources flag parsing", () => {
]);
expect(params.models).to.deep.equal({ gpu: "A100" });
expect(params.mode).to.equal("both"); // default tier
- expect(params.chainId).to.equal(137); // default chain = RPC chainId
+ expect(params.chains).to.deep.equal([{ chainId: 137, tokens: undefined }]); // default chain = RPC chainId
expect(params.orderBy).to.equal("price"); // default for non-free
});
+ it("parses multiple chains and applies token list to each", () => {
+ const params = buildParamsFromFlags(
+ { cpu: "1", chain: "8996, 137", token: "0xA,0xB" },
+ 1,
+ );
+ expect(params.chains).to.deep.equal([
+ { chainId: 8996, tokens: ["0xA", "0xB"] },
+ { chainId: 137, tokens: ["0xA", "0xB"] },
+ ]);
+ });
+
it("defaults orderBy to freeCapacity for free-only searches", () => {
const params = buildParamsFromFlags({ cpu: "1", free: true }, 1);
expect(params.mode).to.equal("free");
@@ -87,7 +98,7 @@ describe("searchResources row building and ordering", () => {
const params = {
resources: [{ resource: "cpu", value: 2 }],
mode: "paid" as const,
- chainId: 8996,
+ chains: [{ chainId: 8996 }],
durationSeconds: 60,
orderBy: "price" as const,
};
@@ -106,6 +117,65 @@ describe("searchResources row building and ordering", () => {
// cheapest token B: price 1 * amount 2 * ceil(60/60)=1 minute = 2
expect(rows[0].estCost).to.equal(2);
expect(rows[0].token).to.equal("0xTOKEN_B");
+ expect(rows[0].chainId).to.equal(8996);
+ });
+
+ it("fans out one row per requested chain the env prices on", () => {
+ const env = makeEnv({
+ fees: {
+ "8996": [{ feeToken: "0xA", prices: [{ id: "cpu", price: 5 }] }],
+ "137": [{ feeToken: "0xB", prices: [{ id: "cpu", price: 1 }] }],
+ },
+ });
+ const multi = {
+ ...params,
+ chains: [{ chainId: 8996 }, { chainId: 137 }],
+ };
+ const rows = buildRows([makeMatch("n", [env])], "paid", multi);
+ // One row per chain (same env, compared side by side), in requested-chain order.
+ expect(rows).to.have.length(2);
+ expect(rows[0].chainId).to.equal(8996);
+ expect(rows[0].estCost).to.equal(10); // 5 * 2 * 1
+ expect(rows[0].token).to.equal("0xA");
+ expect(rows[1].chainId).to.equal(137);
+ expect(rows[1].estCost).to.equal(2); // 1 * 2 * 1
+ expect(rows[1].token).to.equal("0xB");
+ // Ordering by price then interleaves them across envs as usual.
+ const ordered = orderRows(rows, "price");
+ expect(ordered[0].chainId).to.equal(137); // cheapest first
+ });
+
+ it("emits a single unpriced row when the env prices on no requested chain", () => {
+ const env = makeEnv({
+ id: "elsewhere",
+ fees: { "8453": [{ feeToken: "0xB", prices: [{ id: "cpu", price: 1 }] }] },
+ });
+ const rows = buildRows([makeMatch("n", [env])], "paid", {
+ ...params,
+ chains: [{ chainId: 8996 }, { chainId: 137 }],
+ });
+ expect(rows).to.have.length(1);
+ expect(rows[0].estCost).to.equal(null);
+ expect(rows[0].pricedOnChains).to.deep.equal(["8453"]); // surfaced for the "prices elsewhere" hint
+ });
+
+ it("honors a per-chain token filter when pricing", () => {
+ const env = makeEnv({
+ fees: {
+ "8996": [
+ { feeToken: "0xCHEAP", prices: [{ id: "cpu", price: 1 }] },
+ { feeToken: "0xWANT", prices: [{ id: "cpu", price: 4 }] },
+ ],
+ },
+ });
+ const filtered = {
+ ...params,
+ chains: [{ chainId: 8996, tokens: ["0xwant"] }], // case-insensitive
+ };
+ const rows = buildRows([makeMatch("n", [env])], "paid", filtered);
+ // 0xCHEAP is excluded by the filter, so 0xWANT wins: 4 * 2 * 1 = 8
+ expect(rows[0].estCost).to.equal(8);
+ expect(rows[0].token).to.equal("0xWANT");
});
it("leaves estCost null when the env cannot be priced", () => {
@@ -166,7 +236,7 @@ describe("searchResources row building and ordering", () => {
const gpuParams = {
resources: [{ resource: "gpu", value: 2 }],
mode: "paid" as const,
- chainId: 8996,
+ chains: [{ chainId: 8996 }],
orderBy: "resources" as const,
};
const rows = buildRows([makeMatch("gpuNode", [env])], "paid", gpuParams);
@@ -177,7 +247,7 @@ describe("searchResources row building and ordering", () => {
expect(describeRowResources(rows[0], gpuParams)).to.contain("(A100)");
});
- it("records accepted tokens and priced chains on paid rows", () => {
+ it("records accepted tokens per requested chain and all priced chains", () => {
const env = makeEnv({
fees: {
"8996": [
@@ -188,11 +258,14 @@ describe("searchResources row building and ordering", () => {
},
});
const rows = buildRows([makeMatch("n", [env])], "paid", params);
- expect(rows[0].acceptedTokens).to.deep.equal(["0xTOKEN_A", "0xTOKEN_B"]);
+ expect(rows[0].acceptedByChain).to.deep.equal([
+ { chainId: 8996, tokens: ["0xTOKEN_A", "0xTOKEN_B"] },
+ ]);
expect(rows[0].pricedOnChains).to.have.members(["8996", "137"]);
});
- it("filterByToken keeps only envs accepting the chosen token", () => {
+ it("filterMatches drops paid envs not priceable under the per-chain token filter", () => {
+ const p = { ...params, chains: [{ chainId: 8996, tokens: ["0xwant"] }] }; // case-insensitive
const a = makeEnv({
id: "accepts",
fees: { "8996": [{ feeToken: "0xWANT", prices: [{ id: "cpu", price: 1 }] }] },
@@ -201,15 +274,40 @@ describe("searchResources row building and ordering", () => {
id: "rejects",
fees: { "8996": [{ feeToken: "0xOTHER", prices: [{ id: "cpu", price: 1 }] }] },
});
- const rows = buildRows(
- [makeMatch("n1", [a]), makeMatch("n2", [b])],
- "paid",
- params,
- );
- const kept = filterByToken(rows, "0xwant"); // case-insensitive
- expect(kept.map((r) => r.env.id)).to.deep.equal(["accepts"]);
- // No token filter -> everything kept.
- expect(filterByToken(rows, undefined)).to.have.length(2);
+ const rows = buildRows([makeMatch("n1", [a]), makeMatch("n2", [b])], "paid", p);
+ expect(filterMatches(rows, p).map((r) => r.env.id)).to.deep.equal(["accepts"]);
+ // No token filter -> both price on the requested chain, so both kept.
+ const p2 = { ...params, chains: [{ chainId: 8996 }] };
+ const rows2 = buildRows([makeMatch("n1", [a]), makeMatch("n2", [b])], "paid", p2);
+ expect(filterMatches(rows2, p2)).to.have.length(2);
+ });
+
+ it("filterMatches drops paid envs that price on no requested chain", () => {
+ const env = makeEnv({
+ id: "elsewhere",
+ fees: { "8453": [{ feeToken: "0xB", prices: [{ id: "cpu", price: 1 }] }] },
+ });
+ const p = { ...params, chains: [{ chainId: 8996 }] };
+ const rows = buildRows([makeMatch("n", [env])], "paid", p);
+ expect(rows).to.have.length(1); // buildRows keeps the unpriced fallback row
+ expect(filterMatches(rows, p)).to.have.length(0); // ...which filterMatches then drops
+ });
+
+ it("filterMatches drops envs whose resources fall short of the request", () => {
+ const enough = makeEnv({
+ id: "enough",
+ resources: [{ id: "cpu", type: "cpu", max: 4 }],
+ fees: { "8996": [{ feeToken: "0xT", prices: [{ id: "cpu", price: 1 }] }] },
+ });
+ const short = makeEnv({
+ id: "short",
+ resources: [{ id: "cpu", type: "cpu", max: 0 }], // max 0 < requested 2
+ fees: { "8996": [{ feeToken: "0xT", prices: [{ id: "cpu", price: 1 }] }] },
+ });
+ const rows = buildRows([makeMatch("n", [enough, short])], "paid", params);
+ expect(filterMatches(rows, params).map((r) => r.env.id)).to.deep.equal([
+ "enough",
+ ]);
});
it("hides generic fungible/non-fungible kinds but keeps real descriptions", () => {
@@ -225,7 +323,7 @@ describe("searchResources row building and ordering", () => {
{ resource: "disk", value: 1 },
],
mode: "paid" as const,
- chainId: 8996,
+ chains: [{ chainId: 8996 }],
orderBy: "resources" as const,
};
const rows = buildRows([makeMatch("n", [env])], "paid", p);
diff --git a/test/setNode.test.ts b/test/setNode.test.ts
index bd642e9..f69707f 100644
--- a/test/setNode.test.ts
+++ b/test/setNode.test.ts
@@ -36,7 +36,9 @@ describe("Ocean CLI node selection", function () {
const { output } = await runRepl(["help", "getNode", "exit"], {
env: { NODE_URL: undefined },
});
- expect(output).to.contain("Usage: ocean-cli");
+ // Help renders the custom grouped menu (not Commander's default "Usage:" line).
+ expect(output).to.contain("Ocean CLI");
+ expect(output).to.contain("— commands");
// Both new commands must be discoverable from the menu.
expect(output).to.contain("setNode");
expect(output).to.contain("getNode");
diff --git a/test/setup.test.ts b/test/setup.test.ts
index c85cd89..126b27e 100644
--- a/test/setup.test.ts
+++ b/test/setup.test.ts
@@ -23,49 +23,53 @@ describe("Ocean CLI Setup", function () {
exec("npm run cli h", { cwd: projectRoot }, (error, stdout) => {
// Check the stdout for the expected response
try {
- expect(stdout).to.contain("help|h");
+ // Topic group headings (help is now grouped by topic; command signatures
+ // are shown per-command via ` --help`, not in this overview).
+ expect(stdout).to.contain("Node & session");
+ expect(stdout).to.contain("Discover compute providers");
+ expect(stdout).to.contain("Assets — publish, edit, consume");
+ expect(stdout).to.contain("Jobs (C2D):");
+ expect(stdout).to.contain("Services on demand:");
+ expect(stdout).to.contain("Tokens & auth");
+ expect(stdout).to.contain("Escrow payments");
+ expect(stdout).to.contain("Access lists");
+ expect(stdout).to.contain("Persistent storage (buckets)");
+ expect(stdout).to.contain("Admin");
+
+ // Commands are listed as "name (aliases)" followed by their description.
+ expect(stdout).to.contain("help (h)");
expect(stdout).to.contain("Display help for all commands");
- expect(stdout).to.contain("getDDO [options] ");
+ expect(stdout).to.contain("setNodeEnv (useNodeEnv)");
+ expect(stdout).to.contain("searchComputeResources (findComputeNodes)");
+ expect(stdout).to.contain("getDDO");
expect(stdout).to.contain("Gets DDO for an asset using the asset did");
- expect(stdout).to.contain("publish [options] ");
+ expect(stdout).to.contain("publish");
expect(stdout).to.contain(
"Publishes a new asset with access service or compute service",
);
- expect(stdout).to.contain("publishAlgo [options] ");
+ expect(stdout).to.contain("publishAlgo");
expect(stdout).to.contain("Publishes a new algorithm");
- expect(stdout).to.contain(
- "editAsset|edit [options] ",
- );
+ expect(stdout).to.contain("editAsset (edit)");
expect(stdout).to.contain(
"Updates DDO using the metadata items in the file",
);
- expect(stdout).to.contain("download [options] [folder]");
+ expect(stdout).to.contain("download");
expect(stdout).to.contain("Downloads an asset into specified folder");
- expect(stdout).to.contain("allowAlgo [options] ");
+ expect(stdout).to.contain("allowAlgo");
expect(stdout).to.contain("Approves an algorithm to run on a dataset");
- expect(stdout).to.contain(
- "startCompute [options] ",
- );
+ expect(stdout).to.contain("startCompute");
expect(stdout).to.contain("Starts a compute job");
- expect(stdout).to.contain(
- "startFreeCompute [options] ",
- );
+ expect(stdout).to.contain("startFreeCompute");
expect(stdout).to.contain("Starts a FREE compute job");
- expect(stdout).to.contain(
- "stopCompute [options] ",
- );
+ expect(stdout).to.contain("stopCompute");
expect(stdout).to.contain("Stops a compute job");
- expect(stdout).to.contain(
- "getJobStatus [options] [agreementId]",
- );
+ expect(stdout).to.contain("getJobStatus");
expect(stdout).to.contain("Displays the compute job status");
- expect(stdout).to.contain(
- "downloadJobResults [destinationFolder]",
- );
+ expect(stdout).to.contain("downloadJobResults");
expect(stdout).to.contain("Downloads compute job results");
expect(stdout).to.contain("mintOcean");
expect(stdout).to.contain("Mints Ocean tokens");
- expect(stdout).to.contain("getComputeEnvironments");
+ expect(stdout).to.contain("getComputeEnvironments (getC2DEnvs)");
expect(stdout).to.contain("Gets the existing compute environments");
expect(stdout).to.contain("computeStreamableLogs");
expect(stdout).to.contain("Gets the existing compute streamable logs");
@@ -148,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";
+ }
+ },
+ );
+ });
});