Skip to content
LogoLogo

Migrating from v1

A call-by-call map from nautilus v1 to v2

nautilus v2 keeps the builder pattern but sits on a different stack: @oceanprotocol/lib 9.x instead of 3.4.6, ethers v6 instead of v5, ocean-node instead of Aquarius and Provider, and DDO v5 instead of v4.1.0.

This is a call-by-call map. If something is not listed, it did not change.

Upgrading from 2.0.0-beta.2

The signer is wrapped in a nonce guard

Nautilus.create wraps your signer in a nonce guard that serialises sends per address and recovers from nonce collisions (see Concurrency and nonces). getSigner() returns that wrapper, not the object you passed in:

// before
const wallet = nautilus.getSigner() as Wallet
wallet.privateKey
 
// after: keep your own reference
const wallet = new Wallet(key, provider)
const nautilus = await Nautilus.create(wallet, { config })

confirmTransaction(operation, response) takes the signer and the contract the call went to as optional third and fourth arguments, so its error can name why the send failed. To keep nautilus from setting nonces, pass nonceManagement: 'off'. For paid flows, also pass verificationProvider (a second RPC for the same chain, not the signer's own provider or URL, which create refuses): without it, an order refused for its nonce is never sent again.

ocean.js's log lines go through nautilus's logger

ocean.js's own lines (its LoggerInstance) are captured by default: they print as redacted oceanjs.logger.* events on stderr (or to your sink), not on the console, and follow nautilus's level. ocean.js's payload dump after a refused compute start no longer shows the request's nonce and session ids. Code that read ocean.js's lines from console.error (e.g. a test spy) reads them from a sink, or spies on LoggerInstance.error itself; to keep the old output, unredacted:

Nautilus.configureLogging({ oceanjs: 'passthrough' })

See configureLogging.

Access: no silent second payment

access() throws an OrderCheckError instead of ordering anew when it cannot check for an order you already hold. When the on-chain lookup of a previous order fails (the RPC rate-limits it, does not answer, answers with an error, or answers without the data the check needs) or stops short (it cannot read back over the whole order window) and the service costs anything, nothing is bought: buying without the check could pay twice. Nothing was ordered or paid; the same call can be made again. "No previous order found" still orders, as before.

A paid service whose orders never expire (timeout: 0), or whose timeout is longer than 100,000 blocks, now needs orderOnLookupFailure: true for a purchase when no order is found in the last 100,000 blocks: an older one may still be in force. The same refusal comes from an RPC that serves fewer than 100 blocks per eth_getLogs call, or answers too slowly, when the reads end before they cover the service's timeout: beta.2 reported "not found" there and bought again. Use another RPC, and keep paid timeouts at 24 hours or less on an RPC that serves 100 blocks per call (see Known limitations).

// beta.2: a failed lookup ordered anew, silently
const { url } = await nautilus.access({ assetDid })
 
// now
try {
  const { url } = await nautilus.access({ assetDid })
} catch (error) {
  if (!(error instanceof OrderCheckError)) throw error
  // Nothing was paid. Don't buy anyway on your own: that is the second payment this
  // prevents. Show the message (cost, cause, what to do) and let the user decide; retrying
  // later or with another RPC is the safe first step.
  const costs = error.costs.map((c) => `${c.amount} ${c.symbol}`).join(' + ')
  if (costs && (await askUser(`${error.message}\n\nBuy anyway, maybe paying ${costs} twice?`)))
    await nautilus.access({ assetDid, orderOnLookupFailure: true })
}

A free service is still ordered, now with a warning. Only true opts in. compute() makes no such lookup and is unchanged. See OrderCheckError.

Upgrading from 2.0.0-beta.1

VP policies

Write VP policies as objects. VpPolicy is now { policy: string; args?: string }: setVpPolicies() and the vpPolicies of addRequestCredentials() no longer take bare names, and args is a string. The policy server JSON-parses it, so pass structured arguments through JSON.stringify. ocean-node 4.2.x did not index an asset whose vp_policies mixed a name with an object, so publish such an asset again.

 builder.setVpPolicies(CredentialListTypes.ALLOW, [
-  'holder-binding',
-  { policy: 'minimum-credentials', args: 1 }
+  { policy: 'signature' },
+  { policy: 'minimum-credentials', args: '1' }
 ])

minimum-credentials needs its argument: without one it fails in the walt.id verifier for every presentation. Add { policy: 'signature' }: walt.id checks the presentation's own signature only when a VP policy asks for it, and whether the policy server adds it is up to its operator.

Leave out holder-binding for vc+jwt credentials. The beta.1 docs and examples required it. walt.id's holder-binding reads the credential JWT's sub and fails for credentials without sub (common for VCDM 2.0 vc+jwt) and URL-subject credentials such as Gaia-X gx:LegalPerson, so an asset that requires it refuses every holder of such a credential. Edit such assets with setVpPolicies(), and put issuer trust on the credential ({ policy: 'allowed-issuer', args: [...] } in the policies of a requested credential). See publishing a gated asset.

Stored request credentials hold JSON strings. SsiPolicyValue.request_credentials is now StoredRequestCredential[], whose policies are JSON-encoded ('signature' is stored as '"signature"'). Code that reads credentials from a DDO should JSON.parse each policy. RequestCredential, which the builders take, is unchanged.

Editing reads stored policies strictly. Loading an asset for an edit throws on a VP policy, request credential or per-credential policy it cannot read, rather than dropping it and leaving the asset open under the remaining policies. Removing the last address from an allow list now keeps the entry as values: [], which denies everyone, as the node and the policy server read it.

AddressCredential.values is ({ address: string } | string)[]. Assets from other producers can store bare strings, and an edit keeps them: an entry nautilus does not change stays as stored, and one stored as bare strings only stays that way when addresses are added or removed. Read each value as typeof value === 'string' ? value : value.address.

Policy-server sessions

nautilus now opens the policy-server session itself, on every download and compute job that the node checks: whenever the node has a policy server and the asset or service has credentials ({} included). The node's status says whether it has one (isPSConfigured); upstream ocean-node 4.2.0 does not report it, and there initiate tells (a 404 with no body). An asset gated by addresses only needs no wallet any more, and a refusal is reported before anything is ordered.

A custom CredentialProvider implements present() instead of resolve(). nautilus opens the session, reads its id and checks the result (checkSessionId); the provider only answers the openid4vp request, and is only called for a service whose SSIpolicy asks for credentials. Ask challenge.node for anything about the session.

  class MyProvider implements CredentialProvider {
-   readonly interactive = true
-   async resolve(challenge: CredentialChallenge): Promise<PolicyServerPayload | null> {
-     // initiate, present, return the session
-   }
+   async present(challenge: CredentialChallenge): Promise<void> {
+     // answer challenge.redirectUri for challenge.sessionId from the wallet
+   }
  }

Drop StaticCredentialProvider, NoopCredentialProvider and skipCredentials. No provider is the default and works for address-only gates; a session is always opened by nautilus, since the policy server binds it to (consumer, asset, service) and checks it on the same node. skipCredentials is gone from access(), compute() and freeCompute(): a node without a policy server is detected from its status, or from its answer to initiate.

Catch PolicyDeniedError. A refusal by the policy server (initiate answering with its own reply, success: false and a 4xx, or a presentation the verifier did not accept) throws a PolicyDeniedError with did, serviceId, consumerAddress, code (the policy server's status; undefined for a presentation), reason (its message, bounded) and, for a presentation, policyResults (each policy's name, outcome and error; never the presentation). It is not an OceanNodeError. Everything the node says itself still is, with the status: a rejected nonce or signature (401), an asset it has not indexed (404), a policy server it cannot reach (400), as are a network error, a timeout and a 5xx. When the wallet reports the failure itself, nautilus asks the verifier which policies failed and throws the PolicyDeniedError with the wallet's error as its cause. A failure before the verifier (the wallet holds no matching credential, for example), or a refusal whose record names no failed policy, is the wallet's CredentialPresentationError, with a reason and, for a rejection, details.errorMessage. Don't send policyResults to a central log: a policy's error can quote a credential value.

import { PolicyDeniedError } from '@deltadao/nautilus'
 
try {
  await nautilus.access({ assetDid })
} catch (error) {
  if (error instanceof PolicyDeniedError) console.log(error.code, error.reason)
  else throw error
}

Move the session cache to Nautilus.create. WaltIdCredentialProvider no longer takes sessionStore and no longer has clearSessions() or explainFailure(): pass sessionStore to Nautilus.create, and read the failed policies from PolicyDeniedError.policyResults, or ask the node that opened the session. For a download that is the node in the service's serviceEndpoint, not the configured one:

const service = asset.credentialSubject.services[0]
const { verified, policyResults } = await nautilus
  .getNodeClient()
  .forEndpoint(service.serviceEndpoint) // the node that opened the session
  .checkPolicySession(sessionId)

A SessionStore of your own now stores a SessionEntry of { sessionId, createdAt, presented } and must key on the consumer address exactly as given (sessionKeyString(key) does), not lower-cased. It gets only sessions opened without a presentation: a presented session's id lets anyone read the presentation (vp_token) through the node, so it stays in memory unless you set persistPresentedSessions: true for a store no one else can read. Session ids are credentials; keep them out of logs. A cached session is reused for at most sessionTtlMs (new Nautilus.create option, 2 minutes by default).

OceanNodeClient.initializePolicyVerification returns the reply, null or throws. It returns null only for a node without a policy server (a 404 with no body), and hasPolicyServer() then answers false for 10 minutes, unless it knew the node to have one. It throws a PolicyDeniedError for the policy server's refusal and an OceanNodeError with the status otherwise. Pass it policySessionAddress(consumerAddress), the address the node forwards (for a JWT, the token's). hasPolicyServer(), policySessionAddress() and checkPolicySession(sessionId) ({ verified, policyResults }) are new. PolicyServerAction moved to the node client and has only GET_PD and CHECK_SESSION_ID.

WaltIdCredentialProvider checks what the node sends. It refuses a presentation request that is not openid4vp://, whose request_uri, response_uri or presentation_definition_uri is not https:// (or http:// on a loopback host; set allowInsecureTransport: true for a private network), or whose presentation definition asks for a credential type the asset does not request, and it presents only credentials of the requested types. Its signer must be the consumer. setCredentialProvider(undefined) now removes the provider.

Give a gated asset an address allow list. On a node with a policy server, the policy server checks the consumer's address against the asset-level allow list before anything else, so an asset with credentials: {}, or an allow list without an address entry, is refused to everyone. publish() and edit() log a warning for such an asset when the node has a policy server:

assetBuilder.addCredentialAddresses(CredentialListTypes.ALLOW, ['0x…'])

Compute jobs

Job ids are <environmentHash>-<jobId>. compute() and freeCompute() return the id the node gives a started job, and getComputeStatus(), getComputeResult(), streamComputeResult(), getComputeLogs() and stopCompute() take it. beta.1 shortened it to the bare <jobId>, and asked the node for that one; ocean-node then answers with every job of the consumer, and beta.1 took the first. A bare id now throws. To rebuild the full id from a bare one you stored, put the first segment of the job's environment id in front of it. The id must be exactly what ocean-node builds, 0x and 64 hex digits, a dash, then 64 hex digits; anything else throws before a request, with the id's length rather than the id in the message:

- await nautilus.getComputeStatus({ jobId: bareJobId })
+ await nautilus.getComputeStatus({
+   jobId: `${environment.id.split('-')[0]}-${bareJobId}`
+ })

getComputeStatus() returns undefined for a job the node does not know, and reports the job under the id you passed. It is typed NodeComputeJob, which adds the node's environment, resources and payment fields to ComputeJob. OceanNodeClient's computeStatus() and getComputeJob() return NodeComputeJob too, and getComputeJob() returns undefined for an unknown job. getComputeLogs() now returns a ComputeResultStream (it returned unknown). Every job method requires the qualified job id, and getComputeResultUrl() and getComputeResult() throw a RangeError for an index that is not a non-negative safe integer.

Started and stopped jobs are NodeComputeJobs. compute() and freeCompute() return jobs: NodeComputeJob[], and stopCompute() returns NodeComputeJob[], as do OceanNodeClient's computeStart(), freeComputeStart() and computeStop(). They were typed ComputeJob[] but carried the node's environment; code that names the type changes:

- const jobs: ComputeJob[] = await nautilus.stopCompute({ jobId })
+ const jobs: NodeComputeJob[] = await nautilus.stopCompute({ jobId })

A job has finished once its dateFinished is set, whatever its status. A failed job ends below 70 (41 when the algorithm failed, 11 when its image could not be pulled, …), so waiting for 70 or 71 never ends for it. Poll on dateFinished and read the status for how the job ended:

- if ([70, 71].includes(job.status)) done(job)
+ if (job.dateFinished) done(job)

getComputeResult(), streamComputeResult() and getComputeLogs() decide the same way, so they read a failed job's results and algorithmLog. A job at 71 (JobSettle) that lists no output yet counts as not ready rather than as having no output, since the node sets 71 before it writes outputs.tar.

streamComputeResult() streams the job's output (outputs.tar), as getComputeResult() does, rather than the result at index 0 (the image log). Pass resultIndex for another result. It now also throws for a job that has not finished.

getComputeLogs() returns a ComputeResultStream (AsyncIterable<Uint8Array>), not unknown. Once the job has finished (its dateFinished is set), it streams the job's algorithmLog result, since the node serves live logs only while the algorithm runs:

- const logs = await nautilus.getComputeLogs({ jobId })
+ for await (const chunk of await nautilus.getComputeLogs({ jobId }))
+   process.stdout.write(chunk)

Default resources are at least 1 CPU, 1 GB of RAM and 1 GB of disk. Without resources (or with an empty list), each resource defaulted to the environment's minimum, which ocean-node 4.2 environments usually set to 0, and a 0 means no CPU or memory limit for the job. cpu, ram and disk now default to at least 1 within the resource's maximum, other resources (GPUs) to their minimum. A non-empty resources list is sent exactly as given, with nothing added: the node gives each resource it leaves out that resource's minimum. A paid job's escrow quote rises with the larger default request; pass resources to choose.

Algorithm services carry no compute block. nautilus publishes and edits an asset of type algorithm without the dataset-side compute settings (allowRawAlgorithm, allowNetworkAccess, publisherTrustedAlgorithms, publisherTrustedAlgorithmPublishers) on its services. ocean-node reads them from the dataset's service only.

Node errors

Node errors carry the node's message. initialize and getComputeLogs are sent by nautilus over HTTP, so a refusal reads [ocean-node] <operation>: HTTP <status> <statusText>: <the node's text> and the OceanNodeError has status. It used to surface as a JSON parse error (Unexpected token 'U', "Use the in"... is not valid JSON). For the calls that still go through ocean.js, a JSON string answer is unquoted and the error of a JSON object is used; a plain-text answer reads "the node's error answer is not JSON", with ocean.js's parse error on cause. getNodeAddress, getIndexerNonceState, getIndexingState and initializePolicyVerification (for an answer that is not a refusal) use the HTTP <status> form too (HTTP 502 Bad Gateway: … instead of 502 Bad Gateway …). Code that matches on messages should check error.status instead.

resolve() and getAsset() throw an AssetNotFoundError for an asset the node does not serve (an OceanNodeError with status 404 and did). It used to be resolve: HTTP request failed. When the node recorded an indexing failure for the DID, the message carries the node's error and state the record. Over P2P, the node's Not found answer throws it too, without state.

The node's text is sanitized. Control characters and ANSI escapes are removed, more credentials are redacted (vp_token, id_token, request_uri, code, nonce, passwords, API keys, bare JWTs and signatures), URLs keep their origin only, and an error body is read up to 64 KB. For an ocean.js error, OceanNodeError.cause is a sanitized copy (same name) instead of the ocean.js error itself. IndexingError messages are cut at 200 characters too.

Redirects. initialize no longer follows redirects (its query carries the consumer's address and userdata); a redirect throws an OceanNodeError. The read-only node GETs (resolve, waitForIndexer, getIndexingState, getNodeAddress, getIndexerNonceState) refuse a redirect from a public node to a loopback, private or link-local host, unless the node is on one itself.

A JWT's address is read from the token. With a JWT auth, consumerAddress is no longer required: the address is read from the token's address claim, as ocean.js reads it. getComputeLogs with a JWT and no consumerAddress used to throw.

PolicyDeniedError.consumerAddress is the address sent, which for a Signer is its own address, not the one in the request.

Upgrading from 2.0.0-beta.0

2.0.0-beta.1 makes published and edited assets indexable by an unmodified OceanProtocolEnterprise ocean-node 4.2.x. beta.0 stored the bare signed JWS and hashed its claims; the 4.2 node expects a JSON envelope and hashes that, so every beta.0 asset went on chain and was never indexed. Those assets cannot be repaired — publish them again with beta.1.

Switch the remote store to IPFS (or S3). NodePersistentRemoteStore is rejected as the DDO store, because ocean-node 4.2 cannot read a DDO from its own bucket storage. The bootstrap instance is no longer needed for the store:

const bootstrap = await Nautilus.create(signer, { config }) 
const nautilus = await Nautilus.create(signer, { 
  config, 
  remoteStore: new NodePersistentRemoteStore(bootstrap.getNodeClient()) 
}) 
const nautilus = await Nautilus.create(signer, { 
  config, 
  remoteStore: new IpfsRemoteStore({ 
    uploadUrl: 'https://api.pinata.cloud/pinning/pinFileToIPFS', 
    headers: { Authorization: `Bearer ${process.env.PINATA_JWT}` }, 
    probe: 'upload', 
    gatewayUrl: 'https://gateway.pinata.cloud'
  }) 
}) 

Drop encrypt. nautilus is encrypted only: the stored envelope and the on-chain pointer are always node-encrypted (flags 0x02), and the DDO never goes on chain. Passing encrypt: false throws.

await nautilus.publish(asset, { encrypt: false }) 
await nautilus.publish(asset) 

Custom RemoteStores receive the envelope. put(payload) now gets the JSON string {"encryptedData":"0x…"}, not the JWS. Store it byte for byte — its sha256 is the on-chain metadata hash — and return an ipfs, url, s3, arweave or ftp pointer; an ipfs hash must be a CIDv0 or CIDv1. Three optional methods are new: check(), which runs before the first transaction of publish(), completePublish() and edit(); verify(pointer, expectedHash), which runs right before the metadata transaction and reads the stored object back; and remove(pointer) for cleanup, which must accept the redacted pointer from PublishResponse.stored. See writing your own.

waitForIndexer throws instead of returning undefined. It polls every 7 s for up to 5 minutes by default, rather than about 50 minutes, and throws an IndexingError with the node's message as soon as the node records a failure, or an OceanNodeError on timeout or after repeated failed requests. On OceanNodeClient.waitForIndexer, the interval and maxRetries options are replaced by intervalMs and timeoutMs; signal stays. The old keys still work for now, mapped to intervalMs = interval and timeoutMs = interval × maxRetries (maxRetries alone: beta.0's 30 s × maxRetries) with a one-time warning, but will be removed. publish/edit take the same timing:

const indexed = await nautilus.waitForIndexer(did, txid) // AssetV5 | undefined
if (!indexed) throw new Error('not indexed') 
const indexed = await nautilus.waitForIndexer(did, txid, { timeoutMs: 600_000 }) // AssetV5
await nautilus.publish(asset, { waitForIndexer: { timeoutMs: 600_000 } }) 

Allow the provider fees and escrow payments you will pay. The node chooses the provider fee of a download or compute input (its token, its amount and who receives it) and the escrow contract and amount of a paid compute job. nautilus used to approve and pay whatever the node quoted. It now pays a non-zero provider fee only within maxProviderFee or when confirmProviderFees returns true, funds escrow only within maxEscrowPayment or when confirmEscrowPayment returns true, and funds exactly one escrow contract: your config.escrow when you set it, else the chain's EnterpriseEscrow in Ocean's address data (ocean.js's bundled addresses, or ADDRESS_FILE when set), else its Escrow. Otherwise access() and compute() throw a ProviderFeeNotAllowedError or an EscrowPaymentNotAllowedError before any approval, purchase, deposit or order. Zero fees and zero escrow payments need nothing (a zero escrow quote is not checked further, so it may name no contract). Amounts are in the token's smallest unit. Set the ceilings once in Nautilus.create, or per call. The defaults come in pairs, maxProviderFee with confirmProviderFees and maxEscrowPayment with confirmEscrowPayment: a call that sets either option of a pair, even to undefined, uses its own pair and neither default of it.

const nautilus = await Nautilus.create(signer, { config }) 
const nautilus = await Nautilus.create(signer, { 
  config, 
  maxProviderFee: { token: feeToken, amount: parseUnits('0.5', 18) }, 
  maxEscrowPayment: { token: paymentToken, amount: parseUnits('5', 18) } 
}) 

The escrow contract is EnterpriseEscrow by default. ocean-node 4.2.0 quotes the chain's EnterpriseEscrow where Ocean's address data has one (OP Sepolia, Optimism, Sepolia, Ethereum mainnet) and Escrow elsewhere, and nautilus funds that same contract and no other. The escrow ocean.js's ConfigHelper fills in (the plain Escrow) does not count: on OP Sepolia the plain Escrow is refused unless you set it as config.escrow. Setting config.escrow pins that contract and only that one, so do not spread ConfigHelper's config, escrow included, into config. On a chain with no escrow in the address data (Pontus-X devnet among them), set the chain's EnterpriseEscrow contract as config: { escrow: '0x…' }, or paid compute is refused. Direct callers of order(), reuseOrder() and settleOrder() pass maxProviderFee / confirmProviderFees the same way.

Paid compute checks every input before it funds escrow, and serialises jobs per payer. compute() reads and checks every input's pricing before the escrow approval, deposit or authorisation, so an input that cannot be ordered is refused with nothing spent. Within one Nautilus instance, paid jobs that share a chain, payer, payment token and environment account fund escrow, order and start one at a time, so concurrent compute() calls no longer overwrite each other's escrow authorisation. Jobs started from another instance or process with the same wallet are not serialised: run those one after the other.

Also breaking:

  • Paid compute funds escrow itself, in exact amounts, instead of through ocean.js's verifyFundsForEscrowPayment: it approves the escrow contract for the deposit only (not for an amount scaled by the token's decimals twice), deposits what escrow lacks for the job, and authorises the environment's account to lock the job's amount on top of its current locks. A wallet that cannot cover the deposit gets an Error before any transaction, instead of { isValid: false } turned into an error.
  • writeMetadata is no longer exported. Metadata is only written through Nautilus.publish(), completePublish() and edit(), which run every encrypted-only check first. prepareMetadata stays and returns redacted pointers.
  • prepareMetadata no longer takes encrypt (encrypt: false throws). It stores the node-encrypted envelope instead of the bare JWS, and its metadataHash is 0x + sha256 of that envelope instead of the JWS claims. WrittenMetadata gained stored (the redacted store pointer and the envelope hash) and encryptedBy (the URI of the node that encrypted it).
  • signS3Request is not exported (unreleased builds of this change exported it); SigV4 is an internal detail of S3RemoteStore.
  • PublishResponse.stored.pointer (and prepareMetadata's pointer/stored) is redacted: an S3 secretAccessKey, url header values, and a URL's user name, password, query values (all of them; the parameter names stay) and fragment read '<redacted>'. S3RemoteStore.remove() works with the redacted pointer.
  • IpfsRemoteStore reads every envelope back before the metadata transaction, and refuses to publish when it cannot: through gatewayUrl when set, otherwise, for a Kubo uploadUrl (…/api/v0/add), through the same node's /api/v0/cat. Any other uploadUrl (Pinata, your own uploader) needs gatewayUrl, or the new verify: false to publish without the read-back; without either, check() throws before anything is minted and verify() throws instead of passing without a read.
  • Plain http:// is refused for oceanNodeUri, the S3RemoteStore endpoint and the IpfsRemoteStore uploadUrl/gatewayUrl/probe.url/unpin.url, except on localhost, 127.0.0.0/8, ::1 and *.localhost. Opt out with allowInsecureTransport: true on Nautilus.create, S3RemoteStore or IpfsRemoteStore. S3RemoteStore.nodeEndpoint may stay http://: only the node connects to it. URLs are judged as fetch parses them, so http:host or http:/host is plain http:// too, and a URL containing a tab, CR, LF or another control character is refused. A URL with a scheme other than http(s) as oceanNodeUri/nodeUri or a store URL, or a scheme-less host:port (it parses as a scheme), now throws; use https://host:port. Only the S3RemoteStore endpoint takes a bare host, meaning https://. A new OceanNodeClient({ nodeUri }) applies the same rule and throws an OceanNodeError (opt out with its own allowInsecureTransport: true), and so does forEndpoint(uri), which carries the flag over from the client it is called on. Service endpoints on plain http:// (non-loopback) now fail in getFileInfo/encrypt unless allowInsecureTransport: true.
  • nautilus no longer follows redirects on the requests that carry credentials or a body: every S3RemoteStore request, and IpfsRemoteStore uploads, probe, unpin and Kubo cat reads. If your store sits behind a redirect, configure the final URL. The read-only node GETs and the IpfsRemoteStore gatewayUrl read do follow them, so a node behind a reverse proxy that redirects /api/aquarius/… works for waitForIndexer, getIndexingState, getNodeAddress and getIndexerNonceState. A redirect to plain http:// on a non-loopback host is refused there, unless the URL nautilus requested (e.g. the node URI) is plain http:// already, with allowInsecureTransport. Requests ocean.js sends (encrypt, initialize, …) are unchanged.
  • IpfsRemoteStore: move URL credentials (https://user:pass@…) into headers (Authorization: 'Basic …'). A redirecting uploadUrl/probe.url/unpin.url must be replaced by its final URL. A custom unpin service must answer 404 (or 2xx) for a CID that isn't pinned to count as removed.
  • OceanNodeClient.encrypt now times out after 120 s with an OceanNodeError; pass requestTimeoutMs to Nautilus.create or new OceanNodeClient() for slower nodes or signers.
  • Nautilus.create refuses a config.chainId that differs from the signer's chain.
  • An asset.owner other than the signer now throws in publish() and completePublish() before the mint. Publish with the owner's signer, then transfer the NFT if needed.
  • publish() resets service.datatokenAddress on every service before minting, so PublishIncompleteError.datatokens lists only the datatokens this publish() created.
  • PublishedService.tx is optional: it is absent for a datatoken completePublish() reused as it was (reused: true).
  • edit() no longer waits for this instance's previous metadata change to be indexed. Instead, one instance's writes to an NFT are serialized, and a metadata transaction that shares its block with another one for the same NFT throws a MetadataConflictError.
  • edit() no longer writes the lifecycle state taken from the fetched DDO. To change the state in an edit, call setLifecycleState() on the builder; otherwise the state the NFT has on chain at the time of the write is kept (NautilusAsset.hasRequestedLifecycleState tells the two apart).
  • A custom RemoteStore's remove() is now called by nautilus when no metadata transaction can point at the envelope: publish(), completePublish() or edit() stored it and then failed before the transaction was sent, or the transaction was rejected, or mined and reverted. It is never called once that transaction may have been mined. A send error that carries a transaction hash (or ethers' "provider destroyed" for a request already at the RPC) counts as sent: the envelope is kept (cleanup: 'kept'), and stored.txHash names the transaction when its hash is known.
  • The JWS a custom DdoSigner returns is decoded before it is stored, and its payload (vc unwrapped, as the node does) must be the validated DDO. Allowed on top: the JWT registered claims (iss, sub, aud, exp, nbf, iat, jti), type: ['VerifiableCredential'], an issuer that differs only in the case of an Ethereum address (or any issuer when the DDO declares none), and any claim outside a vc wrapper. Anything else that differs is refused before the envelope is stored. The built-in signers already sign exactly this.
  • publish(), completePublish() and edit() check the node before their first transaction and throw an IndexerNonceStuckError when the node would not accept its indexer's next decrypt call, since the asset would not be indexed. Opt out with checkIndexerNonce: false; a node that does not answer the check is skipped.
  • They also refuse a DDO whose signed envelope the node could not decrypt: the node accepts decrypt requests up to 100 KB, so the JWS must stay under about 24 000 characters. Checked before the first transaction from an upper bound of the signed DDO (encrypted files and credential claims at their real size, plus 1024 characters for the JWS header and signature), and exactly before the envelope is stored.
  • The pointer a RemoteStore returns must carry what the node needs for its type: url an absolute http(s) url and a GET/POST method (and string headers, if any); s3 an s3Access with non-empty bucket, objectKey, endpoint, accessKeyId and secretAccessKey; arweave a transactionId that is not a URL or path; ftp an ftp:///ftps:// url. Others are refused before the pointer is encrypted.
  • access(), compute(), order() and reuseOrder() throw a ProviderFeeSignatureError, before any approval, purchase or order, for a provider fee that is missing, lacks a field, or whose signature does not recover to providerFeeAddress the way the datatoken contract verifies it. When the signature fails, access asks the node for a new fee (1.1 s later, at most 3 initialize calls in total) unless the service has timeout: 0; compute calls initializeCompute once.
  • order() and reuseOrder() approve the provider fee themselves, combined into one allowance with the publish-market fee and the purchase where they share a token and spender. If you approve the provider fee before calling them, you can drop that approval (or keep it: an allowance that already covers the sum is not approved again).
  • payer in order()/settleOrder() must be omitted or be the signer's address (any case); another address throws "The payer … is not the signer …" before any allowance read or transaction. It could not work before either: the contracts pull from msg.sender. To pay from another account, sign with that account.
  • waitForIndexer polls every 7 s by default (at most 18 requests a minute, under the node's default rate limit of 30) and backs off on a 429 or a 403 "Too many active connections" instead of counting it as a failed request, never waiting more than 60 s.
  • waitForIndexer({ timeoutMs: Infinity }) (or NaN/negative values for any timing option) now throws an OceanNodeError; pass a large finite number.
  • waitForIndexer over a P2P node URI cuts each lookup off after requestTimeoutMs (default 15 s, capped by the time left) and counts it as a failed lookup towards maxConsecutiveFailures. Raise requestTimeoutMs if your peer needs longer to dial.

New:

  • maxProviderFee, confirmProviderFees, maxEscrowPayment and confirmEscrowPayment on Nautilus.create, access() and compute() (the provider-fee pair on order(), reuseOrder() and settleOrder() too), with the types TokenAmount, ProviderFeeQuote, EscrowPaymentQuote, ProviderFeeLimits and EscrowPaymentLimits.
  • ProviderFeeNotAllowedError (fees, reason) and EscrowPaymentNotAllowedError (payment, reason), both thrown before anything is spent.
  • S3RemoteStore — the envelope in an S3 bucket (AWS S3, Exoscale SOS, MinIO), with a separate write key and a read-only key for the node, verify() with the read key, and remove(pointer) to clean up. Read and write key policies must cover <prefix>nautilus-store-check/*, where check() puts its probes (in the shape of real objects), and a bare 403 on the read-key probe fails check(): a proxy or firewall in front of the bucket must let S3's AccessDenied through. Bucket names must follow the S3 rules (lowercase, 3–63 characters, no IP address), and dotted buckets over https need forcePathStyle: true. The pointer's endpoint always carries a lowercase scheme, and remove() refuses keys put() would not have written. endpoint and nodeEndpoint must not carry user:password@ (the constructor throws): S3 auth uses readCredentials and writeCredentials only.
  • PublishResponse.stored — { pointer, metadataHash }: the store's pointer (for IPFS, the CID to unpin), redacted, and the on-chain hash.
  • OceanNodeClient.getIndexingState({ did } | { nft } | { txId }) — the node's indexing record, the only place it says why an asset was not indexed. Failures are filed by transaction (query { txId }); successes by DID ({ did }).
  • OceanNodeClient.getNodeAddress() — the node's providerAddress. publish, completePublish and edit warn once when the publisher is that key, which makes the indexer's decrypt fail with 401.
  • OceanNodeClient.getIndexerNonceState() and isIndexerNonceSignable() — a read-only check (two GETs) of whether the node will accept its indexer's next decrypt call; IndexerNonceStuckError and PublishOptions.checkIndexerNonce use it.
  • ProviderFeeSignatureError — the provider-fee pre-check above, with the fee, the address ecrecover would return and the number of attempts.
  • RemoteStore.check() and IpfsRemoteStore's probe option — the store is tested before anything is minted. RemoteStore.verify() and IpfsRemoteStore's gatewayUrl (or a Kubo node's /api/v0/cat) — the stored envelope is read back before the metadata transaction; IpfsRemoteStore's verify: false opts out explicitly.
  • PublishIncompleteError and nautilus.completePublish(nftAddress, asset) — finish a publish that failed after the NFT was minted. completePublish checks that the signer owns the NFT and that the configured factory created it, and reuses the NFT's datatokens. It refuses a reused datatoken whose existing pricing differs from the service's pricing config: make the service's setPricing() match what is on chain. When a datatoken would be matched only by creation order and its pricing does not fit, set datatokenAddress on each service to say which datatoken belongs to it. It refuses with "has N transaction(s) not mined yet" while the signer has any pending transaction, unrelated ones included: wait until they are mined or dropped, then call it again. After a PublishIncompleteError with stored.cleanup === 'kept', pass { metadataTxHash: error.stored.txHash } (CompletePublishOptions): it then also refuses while that transaction is pending and once it succeeded, so the metadata is not written twice.
  • PublishedNotIndexed — when the metadata transaction was mined but the wait for the indexer fails, the error carries the full PublishResponse as error.published.
  • MetadataConflictError (published, conflictingTxIds, indexedFirst) — ocean-node indexes only the first metadata event of an asset per block, so nautilus checks the block after every metadata transaction.
  • WaitForIndexerOptions.requestTimeoutMs (default 15 s) and maxConsecutiveFailures (default 5); S3RemoteStore and IpfsRemoteStore take requestTimeoutMs (30 s and 60 s). The store constructors throw for a requestTimeoutMs of 0, a negative number, NaN, Infinity or a non-number, and clamp larger values to 2^31−1 ms.
  • IpfsRemoteStore.remove(pointer) unpins a CID: derived from uploadUrl for Pinata (DELETE /pinning/unpin/<cid>) and Kubo (POST /api/v0/pin/rm), or set with the new unpin: { url, method?, headers? } option.
  • error.stored (type StoredBeforeFailure, also on PublishIncompleteError.stored) — when a write fails after the envelope was stored, the redacted pointer, the hash and what became of the object: 'removed' (the transaction was never sent, was rejected, or was mined and reverted, so nautilus removed it), 'not-removed' or 'kept' (the transaction was sent and may be mined; txHash names it when its hash is known). See when a write fails.
  • S3RemoteStore retries a network error or a 500, 502, 503 or 504 once, freshly signed; other statuses are not retried.

Know: keep the creation envelope (from publish()) and the current one of every asset that should stay indexed. A node reindex replays the asset from its creation event and drops it from the index when that envelope is gone. See cleaning up.

Know: DEPRECATED and REVOKED_BY_PUBLISHER are one-way, so publish() and edit() refuse to write metadata in either state. What the node serves afterwards depends on the node's version and database: ocean-node 4.2.x with Elasticsearch drops the DDO (getAsset answers 404); with Typesense it keeps a short document holding only the NFT state. Either way, read the state from the chain, not from the node. See lifecycle states.

1. Dependencies

"@oceanprotocol/lib": "3.4.6", 
"ethers": "^5.7.2"
"@oceanprotocol/lib": "^9.2.1", 
"ethers": "^6.17.0"

@oceanprotocol/lib re-exports only the v4 DDO type. The DDO v5 types — AssetV5, ServiceV5, MetadataV5 and the rest — are exported by @deltadao/nautilus itself, so you do not need a direct @oceanprotocol/ddo-js dependency:

import type { AssetV5, MetadataV5, ServiceV5 } from '@deltadao/nautilus'

ocean.js 9.2.1 still nests an older ddo-js under @oceanprotocol/lib for its own internals. It is harmless and needs no override.

ddo-js 1.0.0 also makes credentials mandatory on the asset and on every service. The builders always emit one ({} when no access rules are set), so this only affects DDOs you assemble or patch by hand — see Validation.

2. Setting up

ethers v6 removed the providers namespace.

import { Wallet, providers } from 'ethers'
const provider = new providers.JsonRpcProvider('https://rpc.dev.pontus-x.eu') 
import { JsonRpcProvider, Wallet } from 'ethers'
const provider = new JsonRpcProvider('https://rpc.dev.pontus-x.eu') 
 
const signer = new Wallet('0x…', provider)

Nautilus.create now takes an options object rather than a bare Partial<Config>:

const nautilus = await Nautilus.create(signer, { providerUri: '…' }) 
const nautilus = await Nautilus.create(signer, { 
  config: { oceanNodeUri: 'https://node.example.org' } 
}) 

Config fields

v1v2
metadataCacheUrioceanNodeUri
providerUrioceanNodeUri
subgraphUriremoved — pricing comes from chain reads
—escrow, accessListFactory, sdk: 'evm' | 'oasis'

ConfigHelper ships contract addresses for a set of chains, including Pontus-X devnet (32456) and OP Sepolia (11155420). On a chain it does not know, pass the addresses explicitly in config.

v2 needs an OceanProtocolEnterprise ocean-node 4.2.x. For access to the deltaDAO test environment, or to have your own ocean-node run as a service, contact deltaDAO at https://delta-dao.com/contact or contact@delta-dao.com.

3. Reading assets

const asset = await nautilus.getAquariusAsset(did) 
const assets = await nautilus.getAquariusAssets(dids) 
const asset = await nautilus.getAsset(did) 
const assets = await nautilus.getAssets(dids) 

DDO v5 nests everything under credentialSubject, so read through the helpers rather than indexing into the document — that way a future DDO v6 does not break your code:

asset.metadata.name 
asset.services[0].id 
asset.nft.owner 
asset.nft.state 
import { getMetadata, getServices, getOwner, getLifecycleState } from '@deltadao/nautilus'
getMetadata(asset).name 
getServices(asset)[0].id 
getOwner(asset) 
getLifecycleState(asset) 

New: nautilus.query(searchQuery) and nautilus.waitForIndexer(did, txid?, options?).

4. Publishing

Publishing now signs the DDO as a verifiable credential and stores it off chain, writing only a {remote} pointer. So it needs a remote store — see remote stores and signing:

import { IpfsRemoteStore, Nautilus } from '@deltadao/nautilus'
 
const nautilus = await Nautilus.create(signer, {
  config,
  // A Kubo node; Pinata's pinFileToIPFS works too, with a Bearer JWT in `headers`.
  remoteStore: new IpfsRemoteStore({ uploadUrl: 'http://127.0.0.1:5001/api/v0/add' })
})

The store holds a node-encrypted envelope, never the DDO in clear, and the on-chain pointer is node-encrypted too. The node's own storage (NodePersistentRemoteStore) cannot hold DDOs on ocean-node 4.2, so nautilus rejects it.

publish() gained options and returns a little more:

const { nftAddress, services, ddo, setMetadataTxReceipt } = await nautilus.publish(asset) 
const { nftAddress, services, ddo, setMetadataTxReceipt, credential, stored, indexed } =
  await nautilus.publish(asset, { waitForIndexer: true }) 

It is also cheaper: the NFT, its first datatoken and that service's pricing are created in one transaction rather than three.

5. AssetBuilder

Every v1 method still exists. Three behave differently, and there are new ones.

new AssetBuilder(aquariusAsset) 
new AssetBuilder(asset)   // the asset from nautilus.getAsset()
v1v2
setDescription('text')same call; wrapped into a language-tagged object
setLicense('url')same call; wrapped into { name }. Also accepts a License object
addLinks(['url'])same call; projected into v5's { label: url } map. Also accepts a map
setContentLanguage('en')repurposed: no longer a metadata field, it now sets the @language/@direction tag applied to every language-tagged value

Now required: setProvidedBy(). DDO v5's schema requires providedBy, and build() rejects a new asset without it (alongside name and type).

New:

builder
  .setDisplayTitle('A nicer title')
  .addAttachments([{ name: 'terms', fileType: 'pdf', sha256: '…', mirrors: [] }])
  .setIssuer('did:web:example.org')
  .addCredentialAccessList(CredentialListTypes.ALLOW, { chainId: 32456, accessList: '0x…' })
  .addRequestCredentials(CredentialListTypes.ALLOW, [
    { type: 'VerifiableId', format: 'jwt_vc_json' }
  ])
  .setVcPolicies(CredentialListTypes.ALLOW, ['signature', 'not-before'])
  .setVpPolicies(CredentialListTypes.ALLOW, [
    { policy: 'signature' },
    { policy: 'minimum-credentials', args: '1' }
  ])
  .setCredentialMatchRules({ match_allow: 'all', match_deny: 'any' })

Also fixed: reset() now returns to the loaded asset in edit mode rather than emptying the builder, and NFT defaults no longer leak between assets built in the same process.

6. ServiceBuilder

new ServiceBuilder({ aquariusAsset, serviceId }) 
new ServiceBuilder({ asset, serviceId }) 

File types changed, because ocean-node changed what it can read:

v1 FileTypesv2
URLURL — but the object is now UrlFileObject
IPFSIPFS — { type, hash }
ARWEAVEARWEAVE — { type, transactionId }
GRAPHQLremoved
SMARTCONTRACTremoved
—S3, FTP, NODE_PERSISTENT_STORAGE (new)

setName() is effectively required now (v5 requires Service.name); build() defaults it rather than failing.

addTrustedAlgorithms() finally honours serviceIds, because v5's PublisherTrustedAlgorithms carries a required serviceId:

builder.addTrustedAlgorithms([{ did: 'did:ope:…', serviceIds: ['service-a', 'service-b'] }])

New: setState(), setDisplayName(), setDataSchema(), setInputSchema(), setOutputSchema(), and per-service gating via addCredentialAddresses() / addRequestCredentials().

7. ConsumerParameterBuilder

Two v1 workarounds are gone, because v5 fixed what forced them:

builder.setType('select').addOption({ eu: 'Europe' })
// v4 emitted options as a JSON string
// v5 emits a structured array
 
builder.setDefault(false)
// v4 coerced this to the string "false"
// v5 keeps the boolean

8. Access

const url: string = await nautilus.access({ assetDid }) 
const { url, transferTxId, reusedOrder } = await nautilus.access({ assetDid }) 

If the node has a policy server and the asset or service has credentials, nautilus opens the policy-server session before placing any order — so a refusal costs nothing. See §10.

A non-zero provider fee is paid only within maxProviderFee or with confirmProviderFees; v1 paid whatever the node quoted. See Upgrading from 2.0.0-beta.0.

Two refusals come before any node call other than resolving the asset:

  • access() downloads from access services only. A serviceId naming a compute service throws, pointing to compute() / freeCompute(), instead of reaching the node's initialize.
  • userdata must fit the service's consumer parameters: a plain object, every required parameter present, text a string, number a finite number ('5' is refused), boolean a boolean, select the first key of one of its options (a select without usable options takes no value), any other type a string, a finite number or a boolean, and no undeclared keys when the service declares any. A service that declares none takes any keys, each with a string, a finite number or a boolean: an object or an array, which reached the file's URL as [object Object], is refused. Otherwise a ConsumerParameterError (issues, did, serviceId, field) is thrown. v1 and 2.0.0-beta.1 sent whatever was passed. Fix the values, or check them first with checkConsumerParameters(service.consumerParameters, userdata). Keys set to undefined or null are no longer sent, nor is an object they leave empty.

9. Compute

Compute changed the most, because C2D v2 is a different model.

const job = await nautilus.compute({ dataset, algorithm, additionalDatasets }) 
const { jobs, environment, orders } = await nautilus.compute({ 
  dataset, algorithm, additionalDatasets, 
  computeEnv: env.id,          // pick deliberately; v1 silently used [0]
  resources: [{ id: 'cpu', amount: 2 }, { id: 'ram', amount: 2 }], 
  paymentToken: '0x…', 
  maxJobDuration: 3600
}) 
  • Environments: nautilus.getComputeEnvironments() lists them with their resources, limits and per-chain fees. resources and paymentToken default from the environment.
  • Escrow: paid jobs lock funds in an escrow contract. nautilus funds and authorises it from what the node quotes, in exact amounts, only in the pinned contract (your config.escrow, else the chain's EnterpriseEscrow, else its Escrow) and only within maxEscrowPayment (or with confirmEscrowPayment).
  • Free compute: nautilus.freeCompute({ dataset, algorithm }) — no order, no escrow, no payment token. Requires an environment exposing free.
  • Output: ComputeOutput is now { remoteStorage?, encryption? }. The old publishAlgorithmLog / publishOutput flags no longer exist.
  • Renamed: getComputeEnviroment (sic) → getComputeEnvironments.
  • New: nautilus.streamComputeResult(), nautilus.getComputeLogs().

stopCompute and getComputeStatus no longer need a providerUri; they default to the node the instance is configured with.

A job is addressed by the jobId compute() and freeCompute() return, <environmentHash>-<jobId>. getComputeResult() and streamComputeResult() read the job's output result, outputs.tar, a tar archive of what the algorithm wrote to its outputs folder.

Each input's userdata and the algorithm's algocustomdata are checked like a download's userdata (see §8): against the service's consumer parameters and the algorithm metadata's, respectively (metadata.algorithm.consumerParameters, else, when absent or empty, metadata.algorithm.container.consumerParameters). A mismatch throws a ConsumerParameterError before the environment is read or anything is sent. Check algocustomdata first with checkConsumerParameters(getAlgorithmConsumerParameters(algorithm), algocustomdata).

10. Credential-gated assets

New in v2. nautilus opens the policy-server session itself and carries it into the download or compute call. An asset gated by addresses only needs nothing more. For an asset whose SSIpolicy asks for credentials, configure a credential provider: it answers the presentation request from your wallet (fetch the presentation definition, authenticate, match credentials, present), and nautilus checks that the verifier accepted it.

import { Nautilus, WaltIdCredentialProvider } from '@deltadao/nautilus'
 
const bootstrap = await Nautilus.create(signer, { config })
 
const nautilus = await Nautilus.create(signer, {
  config,
  credentials: new WaltIdCredentialProvider(bootstrap.getNodeClient(), {
    walletApi: 'https://wallet.example.org'
    // Headless by default: all matching credentials, the wallet's first DID.
    // Supply onSelectCredentials / onSelectDid to drive a UI instead.
  })
})
 
// Nothing else changes at the call site.
const { url } = await nautilus.access({ assetDid: 'did:ope:…' })

See credential-gated assets for the full picture.

11. Validation

New in v2, and worth using: DDO validation is entirely local, so it costs nothing and it names the failing field.

import { validate } from '@deltadao/nautilus'
 
const { valid, errors } = await validate(ddo)
// errors: { providedBy: ['Less than 1 values'] }

publish() and edit() run this automatically before spending gas. See validation.

12. Removed

The full removal table
RemovedReplacement
utils/aquarius.ts, utils/provider.tsOceanNodeClient
utils/subgraph/*, getAccessDetailschain reads — getPricingInfo, getOrderPrice
AccessDetails, AssetWithAccessDetails, OrderPriceAndFeesPricingInfo, OrderPrice
local ComputeAsset / ComputeAlgorithmComputeAssetRef / ComputeAlgorithmRef
transformAquariusAssetToDDOunnecessary — v5 keeps indexed data separate
getComputeEnviromentgetComputeEnvironments