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 anErrorbefore any transaction, instead of{ isValid: false }turned into an error. writeMetadatais no longer exported. Metadata is only written throughNautilus.publish(),completePublish()andedit(), which run every encrypted-only check first.prepareMetadatastays and returns redacted pointers.prepareMetadatano longer takesencrypt(encrypt: falsethrows). It stores the node-encrypted envelope instead of the bare JWS, and itsmetadataHashis0x+ sha256 of that envelope instead of the JWS claims.WrittenMetadatagainedstored(the redacted store pointer and the envelope hash) andencryptedBy(the URI of the node that encrypted it).signS3Requestis not exported (unreleased builds of this change exported it); SigV4 is an internal detail ofS3RemoteStore.PublishResponse.stored.pointer(andprepareMetadata'spointer/stored) is redacted: an S3secretAccessKey,urlheader 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.IpfsRemoteStorereads every envelope back before the metadata transaction, and refuses to publish when it cannot: throughgatewayUrlwhen set, otherwise, for a KubouploadUrl(…/api/v0/add), through the same node's/api/v0/cat. Any otheruploadUrl(Pinata, your own uploader) needsgatewayUrl, or the newverify: falseto publish without the read-back; without either,check()throws before anything is minted andverify()throws instead of passing without a read.- Plain
http://is refused foroceanNodeUri, theS3RemoteStoreendpointand theIpfsRemoteStoreuploadUrl/gatewayUrl/probe.url/unpin.url, except onlocalhost,127.0.0.0/8,::1and*.localhost. Opt out withallowInsecureTransport: trueonNautilus.create,S3RemoteStoreorIpfsRemoteStore.S3RemoteStore.nodeEndpointmay stayhttp://: only the node connects to it. URLs are judged asfetchparses them, sohttp:hostorhttp:/hostis plainhttp://too, and a URL containing a tab, CR, LF or another control character is refused. A URL with a scheme other thanhttp(s)asoceanNodeUri/nodeUrior a store URL, or a scheme-lesshost:port(it parses as a scheme), now throws; usehttps://host:port. Only theS3RemoteStoreendpointtakes a bare host, meaninghttps://. Anew OceanNodeClient({ nodeUri })applies the same rule and throws anOceanNodeError(opt out with its ownallowInsecureTransport: true), and so doesforEndpoint(uri), which carries the flag over from the client it is called on. Service endpoints on plainhttp://(non-loopback) now fail ingetFileInfo/encryptunlessallowInsecureTransport: true. - nautilus no longer follows redirects on the requests that carry credentials or a body:
every
S3RemoteStorerequest, andIpfsRemoteStoreuploads,probe,unpinand Kubocatreads. If your store sits behind a redirect, configure the final URL. The read-only node GETs and theIpfsRemoteStoregatewayUrlread do follow them, so a node behind a reverse proxy that redirects/api/aquarius/…works forwaitForIndexer,getIndexingState,getNodeAddressandgetIndexerNonceState. A redirect to plainhttp://on a non-loopback host is refused there, unless the URL nautilus requested (e.g. the node URI) is plainhttp://already, withallowInsecureTransport. Requests ocean.js sends (encrypt,initialize, …) are unchanged. IpfsRemoteStore: move URL credentials (https://user:pass@…) intoheaders(Authorization: 'Basic …'). A redirectinguploadUrl/probe.url/unpin.urlmust be replaced by its final URL. A customunpinservice must answer 404 (or 2xx) for a CID that isn't pinned to count as removed.OceanNodeClient.encryptnow times out after 120 s with anOceanNodeError; passrequestTimeoutMstoNautilus.createornew OceanNodeClient()for slower nodes or signers.Nautilus.createrefuses aconfig.chainIdthat differs from the signer's chain.- An
asset.ownerother than the signer now throws inpublish()andcompletePublish()before the mint. Publish with the owner's signer, then transfer the NFT if needed. publish()resetsservice.datatokenAddresson every service before minting, soPublishIncompleteError.datatokenslists only the datatokens thispublish()created.PublishedService.txis optional: it is absent for a datatokencompletePublish()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 aMetadataConflictError.edit()no longer writes the lifecycle state taken from the fetched DDO. To change the state in an edit, callsetLifecycleState()on the builder; otherwise the state the NFT has on chain at the time of the write is kept (NautilusAsset.hasRequestedLifecycleStatetells the two apart).- A custom
RemoteStore'sremove()is now called by nautilus when no metadata transaction can point at the envelope:publish(),completePublish()oredit()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'), andstored.txHashnames the transaction when its hash is known. - The JWS a custom
DdoSignerreturns is decoded before it is stored, and its payload (vcunwrapped, 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'], anissuerthat differs only in the case of an Ethereum address (or any issuer when the DDO declares none), and any claim outside avcwrapper. Anything else that differs is refused before the envelope is stored. The built-in signers already sign exactly this. publish(),completePublish()andedit()check the node before their first transaction and throw anIndexerNonceStuckErrorwhen the node would not accept its indexer's next decrypt call, since the asset would not be indexed. Opt out withcheckIndexerNonce: 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
RemoteStorereturns must carry what the node needs for its type:urlan absolutehttp(s)urland aGET/POSTmethod(and stringheaders, if any);s3ans3Accesswith non-emptybucket,objectKey,endpoint,accessKeyIdandsecretAccessKey;arweaveatransactionIdthat is not a URL or path;ftpanftp:///ftps://url. Others are refused before the pointer is encrypted. access(),compute(),order()andreuseOrder()throw aProviderFeeSignatureError, before any approval, purchase or order, for a provider fee that is missing, lacks a field, or whose signature does not recover toproviderFeeAddressthe way the datatoken contract verifies it. When the signature fails,accessasks the node for a new fee (1.1 s later, at most 3initializecalls in total) unless the service hastimeout: 0;computecallsinitializeComputeonce.order()andreuseOrder()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).payerinorder()/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 frommsg.sender. To pay from another account, sign with that account.waitForIndexerpolls every 7 s by default (at most 18 requests a minute, under the node's default rate limit of 30) and backs off on a429or a403"Too many active connections" instead of counting it as a failed request, never waiting more than 60 s.waitForIndexer({ timeoutMs: Infinity })(orNaN/negative values for any timing option) now throws anOceanNodeError; pass a large finite number.waitForIndexerover a P2P node URI cuts each lookup off afterrequestTimeoutMs(default 15 s, capped by the time left) and counts it as a failed lookup towardsmaxConsecutiveFailures. RaiserequestTimeoutMsif your peer needs longer to dial.
New:
maxProviderFee,confirmProviderFees,maxEscrowPaymentandconfirmEscrowPaymentonNautilus.create,access()andcompute()(the provider-fee pair onorder(),reuseOrder()andsettleOrder()too), with the typesTokenAmount,ProviderFeeQuote,EscrowPaymentQuote,ProviderFeeLimitsandEscrowPaymentLimits.ProviderFeeNotAllowedError(fees,reason) andEscrowPaymentNotAllowedError(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, andremove(pointer)to clean up. Read and write key policies must cover<prefix>nautilus-store-check/*, wherecheck()puts its probes (in the shape of real objects), and a bare 403 on the read-key probe failscheck(): a proxy or firewall in front of the bucket must let S3'sAccessDeniedthrough. Bucket names must follow the S3 rules (lowercase, 3–63 characters, no IP address), and dotted buckets over https needforcePathStyle: true. The pointer's endpoint always carries a lowercase scheme, andremove()refuses keysput()would not have written.endpointandnodeEndpointmust not carryuser:password@(the constructor throws): S3 auth usesreadCredentialsandwriteCredentialsonly.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'sproviderAddress.publish,completePublishandeditwarn once when the publisher is that key, which makes the indexer's decrypt fail with 401.OceanNodeClient.getIndexerNonceState()andisIndexerNonceSignable()— a read-only check (twoGETs) of whether the node will accept its indexer's next decrypt call;IndexerNonceStuckErrorandPublishOptions.checkIndexerNonceuse it.ProviderFeeSignatureError— the provider-fee pre-check above, with the fee, the addressecrecoverwould return and the number of attempts.RemoteStore.check()andIpfsRemoteStore'sprobeoption — the store is tested before anything is minted.RemoteStore.verify()andIpfsRemoteStore'sgatewayUrl(or a Kubo node's/api/v0/cat) — the stored envelope is read back before the metadata transaction;IpfsRemoteStore'sverify: falseopts out explicitly.PublishIncompleteErrorandnautilus.completePublish(nftAddress, asset)— finish a publish that failed after the NFT was minted.completePublishchecks 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'ssetPricing()match what is on chain. When a datatoken would be matched only by creation order and its pricing does not fit, setdatatokenAddresson 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 aPublishIncompleteErrorwithstored.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 fullPublishResponseaserror.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) andmaxConsecutiveFailures(default 5);S3RemoteStoreandIpfsRemoteStoretakerequestTimeoutMs(30 s and 60 s). The store constructors throw for arequestTimeoutMsof 0, a negative number,NaN,Infinityor a non-number, and clamp larger values to 2^31−1 ms.IpfsRemoteStore.remove(pointer)unpins a CID: derived fromuploadUrlfor Pinata (DELETE /pinning/unpin/<cid>) and Kubo (POST /api/v0/pin/rm), or set with the newunpin: { url, method?, headers? }option.error.stored(typeStoredBeforeFailure, also onPublishIncompleteError.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;txHashnames it when its hash is known). See when a write fails.S3RemoteStoreretries 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
| v1 | v2 |
|---|---|
metadataCacheUri | oceanNodeUri |
providerUri | oceanNodeUri |
subgraphUri | removed — 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()| v1 | v2 |
|---|---|
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 FileTypes | v2 |
|---|---|
URL | URL — but the object is now UrlFileObject |
IPFS | IPFS — { type, hash } |
ARWEAVE | ARWEAVE — { type, transactionId } |
GRAPHQL | removed |
SMARTCONTRACT | removed |
| — | 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 boolean8. 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 fromaccessservices only. AserviceIdnaming acomputeservice throws, pointing tocompute()/freeCompute(), instead of reaching the node'sinitialize.userdatamust fit the service's consumer parameters: a plain object, everyrequiredparameter present,texta string,numbera finite number ('5'is refused),booleana boolean,selectthe first key of one of its options (aselectwithout 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 aConsumerParameterError(issues,did,serviceId,field) is thrown. v1 and 2.0.0-beta.1 sent whatever was passed. Fix the values, or check them first withcheckConsumerParameters(service.consumerParameters, userdata). Keys set toundefinedornullare 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.resourcesandpaymentTokendefault 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'sEnterpriseEscrow, else itsEscrow) and only withinmaxEscrowPayment(or withconfirmEscrowPayment). - Free compute:
nautilus.freeCompute({ dataset, algorithm })— no order, no escrow, no payment token. Requires an environment exposingfree. - Output:
ComputeOutputis now{ remoteStorage?, encryption? }. The oldpublishAlgorithmLog/publishOutputflags 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
| Removed | Replacement |
|---|---|
utils/aquarius.ts, utils/provider.ts | OceanNodeClient |
utils/subgraph/*, getAccessDetails | chain reads — getPricingInfo, getOrderPrice |
AccessDetails, AssetWithAccessDetails, OrderPriceAndFees | PricingInfo, OrderPrice |
local ComputeAsset / ComputeAlgorithm | ComputeAssetRef / ComputeAlgorithmRef |
transformAquariusAssetToDDO | unnecessary — v5 keeps indexed data separate |
getComputeEnviroment | getComputeEnvironments |