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