Skip to content
LogoLogo

Remote stores

Where the signed DDO actually lives

In v1 the DDO went on chain. In v2 it does not: nautilus signs the document as a verifiable credential, has the node encrypt it, stores the result off chain, and writes only an encrypted pointer — { remote: <StorageObject> } — into the NFT's metadata.

That is why publishing needs a remote store, and why publish throws without one.

Loading diagram...

The publish path: the document is signed, encrypted by the node and stored off chain, and only its encrypted pointer is written on chain, where ocean-node dereferences it to index the asset.

Everything is always encrypted. Neither the store nor the chain ever sees the DDO, or where it is stored, in clear. nautilus ships two stores: IpfsRemoteStore (the recommended default) and S3RemoteStore.

IPFS

IpfsRemoteStore posts the envelope to any IPFS upload endpoint and reads the CID out of the response. It needs no node client, so it goes straight into Nautilus.create.

A Kubo node

const  = await .(, {
  ,
  : new ({ 
    : 'http://127.0.0.1:5001/api/v0/add', 
    : 'upload'
  }) 
})

uploadUrl is the Kubo RPC add endpoint. nautilus reads every envelope back from the same node (/api/v0/cat) before the metadata transaction; see reading it back. Add headers for an authenticated gateway, and extractCid if your gateway returns the CID under a key other than the usual Hash/cid/IpfsHash. Whatever it returns must be a CIDv0 (Qm…) or a base32/base36 CIDv1 (b…/k…).

uploadUrl (and gatewayUrl, probe.url, unpin.url) must be https://, except on localhost, 127.0.0.0/8, ::1 and *.localhost — the local Kubo node above is fine. Pass allowInsecureTransport: true to accept plain http:// elsewhere, e.g. on a private network. None of them may carry user:password@ credentials; put those in headers (Authorization: 'Basic …'). Only the gatewayUrl read follows redirects: every other request carries your headers or the envelope, so a redirect fails it.

If you run Kubo yourself, apply its server profile (ipfs config profile apply server). With the default settings it announces private and loopback addresses to the whole network.

Pinata

Pinata works through its file endpoint, pinFileToIPFS, with a JWT as a Bearer token. Keep the JWT in the environment, not in code:

# .env
PINATA_JWT="<your Pinata API JWT>"
const  = new ({
  : 'https://api.pinata.cloud/pinning/pinFileToIPFS', 
  : { : `Bearer ${..}` }, 
  : 'upload', 
  : 'https://gateway.pinata.cloud'
})

The API key needs the pin scope (pinFileToIPFS) to publish, and the unpin scope for remove(). The CID is read from Pinata's IpfsHash key without any configuration. gatewayUrl is how nautilus reads the envelope back before the metadata transaction; Pinata (like any upload endpoint other than Kubo) needs it, or verify: false (see reading it back).

Cleaning up

IpfsRemoteStore.remove(pointer) unpins a CID, for example the PublishResponse.stored.pointer of an intermediate version a later edit has superseded, or of a revoked asset. For Pinata (DELETE /pinning/unpin/<cid>) and Kubo (POST /api/v0/pin/rm?arg=<cid>) the unpin call is derived from uploadUrl; for any other service pass unpin: { url: 'https://…/{cid}', method }:

const  = new ({
  : 'https://api.pinata.cloud/pinning/pinFileToIPFS',
  : { : `Bearer ${..}` },
  : 'https://gateway.pinata.cloud'
})
const  = await .(new ('0x1234'), {  })
 
const  = await .() // keep: the creation envelope
const  = await .() // superseded by the next edit
const  = await .() // keep: the current envelope
 
await .(..) 

A CID that is no longer pinned counts as removed. Unpinning releases your service's copy; IPFS has no delete, so other nodes or gateways that fetched the CID may serve it until they garbage-collect it. The envelope is ciphertext either way.

Checking the store before anything is spent

The DID derives from the NFT address, so the DDO can only be stored after the NFT is minted. A store that fails only then would leave an NFT without metadata behind. So publish(), completePublish() and edit() call the store's optional check() before their first transaction.

IpfsRemoteStore first makes sure it will be able to read the envelope back (a gatewayUrl, a Kubo uploadUrl, or verify: false), and then runs whatever probe you configured:

probeWhat check() does
'upload'Uploads a fixed probe of a few bytes (always the same CID). The only check that catches a pinning-service key without upload scope — Pinata reports a missing scope only on a real upload.
{ url, method?, headers? }A test call, e.g. Pinata's https://api.pinata.cloud/data/testAuthentication. Must answer 2xx. Sends headers when given, otherwise the upload headers — but only to a url on the same origin as uploadUrl.
omittedNo check.

If something still fails after the mint, publish() throws a PublishIncompleteError, and completePublish finishes the publish on the NFT that already exists. What happens to an envelope that was already stored is described in when a write fails.

Reading it back before the transaction

After put() and right before the metadata transaction, nautilus calls the store's optional verify(): it reads the stored envelope back the way the node will and checks the hash, so a store that changed the bytes, or a pointer the node cannot read, fails here, before anything is on chain.

IpfsRemoteStore reads the CID back:

ConfigurationHow
gatewayUrlGET <gatewayUrl>/ipfs/<cid>, as the node does. Ideally the node's own IPFS_GATEWAY: then it also proves the node can fetch it.
a Kubo uploadUrl (…/api/v0/add) without gatewayUrlPOST /api/v0/cat?arg=<cid> on the same node, with the upload headers. It proves the node you uploaded to holds the exact bytes, not that the ocean-node's gateway reaches them.
any other uploadUrl (Pinata, your own uploader) without gatewayUrlNone: check() throws before anything is minted. Set gatewayUrl, or opt out with verify: false.
verify: falseNone, by your choice. Nothing checks the stored bytes before the metadata transaction.

With a gateway:

const  = new ({
  : 'https://api.pinata.cloud/pinning/pinFileToIPFS',
  : { : `Bearer ${..}` },
  : 'upload',
  : 'https://gateway.pinata.cloud' // fetches <gatewayUrl>/ipfs/<cid>
})

Without one, publishing unverified is an explicit choice:

const  = new ({
  : 'https://uploader.example.com/files',
  : { : `Bearer ${..}` },
  : 'upload',
  : false // nothing reads the envelope back before the metadata transaction
})

S3RemoteStore always verifies, with the read key.

S3

S3RemoteStore writes the envelope to an S3 bucket — AWS S3, Exoscale SOS, MinIO or any other S3-compatible store — and the node reads it back with the AWS SDK. Like IPFS, it needs no node client.

ocean-node has no anonymous S3 read: the pointer always carries a key pair, and that pointer is written on chain. So the store works with two keys.

Setup checklist

  1. A bucket, private, with a prefix for DDOs, e.g. ddo/.
  2. A write key for the publisher, allowed to s3:PutObject and s3:DeleteObject under the prefix. It never leaves your machine.
  3. A read-only key scoped to the prefix (s3:GetObject only). It goes into the node-encrypted on-chain pointer of every asset, forever.
  4. check() probes both before anything is minted: it uploads a probe with the write key, shaped like a real object (<prefix>nautilus-store-check/<random hex>.json), reads it back with the read key, makes sure the read key can neither write nor delete (unless both are the same key) — only an explicit 403 AccessDenied counts as "cannot" — and deletes the probes. It does not try version-level actions, so on a versioned bucket make sure the read key has no s3:DeleteObjectVersion either. verify() reads every stored envelope back with the read key before the metadata transaction.
  5. Endpoints. endpoint is where you upload, and must be https:// unless it is a loopback host or you pass allowInsecureTransport: true. nodeEndpoint is what the node is told, when it reaches the bucket differently (127.0.0.1:9000 for you, minio:9000 inside Docker); it defaults to endpoint and may be http://, since only the node connects to it. The pointer gets it with an explicit, lowercase scheme (none means https://). Signed requests never follow a redirect; S3 redirects when region or endpoint does not match the bucket, and the error says so.
  6. Addressing. forcePathStyle: true for MinIO and other path-style stores; the default is virtual-host style (bucket.endpoint/key). On an IP address or localhost endpoint virtual-host style cannot work, so the constructor throws until you set forcePathStyle (or nodeForcePathStyle for nodeEndpoint). A bucket name with dots needs forcePathStyle: true over https://, since bucket.with.dots.host does not match the endpoint's certificate. The bucket name must follow the S3 naming rules.
  7. Keys. prefix must not contain empty, . or .. segments, a leading / or a backslash.
  8. Region. Defaults to us-east-1, as on the node. On Exoscale, it is the zone.

Exoscale SOS

const  = await .(, {
  ,
  : new ({ 
    : 'https://sos-de-fra-1.exo.io', // https://sos-<zone>.exo.io
    : 'de-fra-1', // the zone
    : 'my-ddos', 
    : 'ddo/', 
    : { 
      : ..!, 
      : ..!
    }, 
    : { 
      : ..!, 
      : ..!
    } 
  }) 
})

Both addressing styles work on SOS.

MinIO

const  = new ({
  : 'http://127.0.0.1:9000', 
  : 'http://minio:9000', 
  : true, 
  : 'ddos',
  : 'ddo/',
  : {
    : ..!,
    : ..!
  },
  : {
    : ..!,
    : ..!
  }
})

Objects are stored as <prefix><DID hash>/<envelope sha256>.json (prefix is prepended as is, so give it its trailing /), and an edit writes a new object next to the old one, so the live version never changes under its pointer. Clean up with remove(), using the write key, but keep the creation and the current envelope of every asset that should stay indexed (see cleaning up). It takes the redacted pointer from PublishResponse.stored as is, and only deletes keys of that exact shape under prefix in the configured bucket:

const {  } = await .()
 
// later, once the asset is revoked for good: a live asset needs this creation envelope
await .(.) 

See S3RemoteStore for every option.

When a write fails after the envelope is stored

publish(), completePublish() and edit() store the envelope before the metadata transaction, so a failure in between leaves an object in the store. What nautilus does with it depends on whether that transaction may exist:

The failureThe transactionThe stored objecterror.stored.cleanup
encrypting the pointer, verify(), building the transaction (permission, gas estimate), a wallet or RPC refusal (ACTION_REJECTED, INSUFFICIENT_FUNDS…, not a nonce refusal) whose error carries no transaction hash, a mined revert (also of a sped-up replacement), a cancelled or replaced transactionnever sent, or changed nothingremoved with the store's remove()'removed'
the same, but the store has no remove() or it failednothing points at itleft'not-removed', with removeError
refused for its nonce (NONCE_EXPIRED "nonce too low", REPLACEMENT_UNDERPRICED, "already known") and not got through by the nonce guard (also when its resend was then refused before the broadcast), proven never mined: none of its signed transactions known on two reads (the second through verificationProvider when set), the account's mined count past their nonces, and no MetadataCreated/MetadataUpdated of the NFT with this metadataHash, read from each RPC up to a block at which the account had mined past themnever minedremoved with the store's remove()'removed'
refused for its nonce, not proven (the RPC knows it, the slot may still be free, an event matches, a read failed, the refusal carries no signed transaction, or a resend may have gone out: attemptsComplete: false)may be mined: "nonce too low" also answers a transaction already minedkept'kept', with txHash of the last attempt
sent, then no receipt (timeout, network error, any send error that carries the transaction hash, such as ethers' info.sendTransactionHash; on sdk: 'oasis' also a rejection or revert, which ocean.js does not tell apart)may be minedkept'kept', with txHash when known
after the receipt (waitForIndexer, MetadataConflictError)minedkept, it is the live version— (the error carries published)

error.stored also carries the redacted pointer and the metadataHash, and the error message says what happened. A PublishIncompleteError copies it to its own stored; with 'kept' its message no longer claims the NFT has no metadata, because the transaction may still land. Wait until it is mined or dropped before calling completePublish(), which refuses an NFT that already has metadata.

try {
  await .()
} catch () {
  if ( instanceof  && .?. === 'not-removed') 
    .('unused envelope left in the store:', ..) 
  throw 
}

An object that put() itself failed to confirm (a timeout on the upload) has no pointer, so nautilus cannot know about it; for S3 such objects sit under <prefix><DID hash>/.

Browser use (CORS)

Both stores talk to their service with plain fetch, so they run in a browser — but only if the service answers CORS for your app's origin. Without it the browser blocks the request and check(), put() or verify() fails with a network error.

S3 buckets

S3RemoteStore sends signed requests with the authorization, content-type, x-amz-content-sha256 and x-amz-date headers, which makes the browser send a preflight. The bucket must allow:

Originsyour app's origin, e.g. https://app.example.com
MethodsPUT (upload, check()), GET (verify(), check()), DELETE (remove(), check())
Headersauthorization, content-type, x-amz-content-sha256, x-amz-date

nautilus reads only the status and the body, so no response headers need exposing. The host header is part of the signature; browsers set it themselves, to the same value, so it still matches.

On AWS S3, and on S3-compatible stores that implement the bucket CORS API such as Exoscale SOS, save the rules as cors.json:

{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://app.example.com"],
      "AllowedMethods": ["GET", "PUT", "DELETE"],
      "AllowedHeaders": ["authorization", "content-type", "x-amz-content-sha256", "x-amz-date"],
      "MaxAgeSeconds": 3600
    }
  ]
}

and apply them with the AWS CLI (for SOS, add --endpoint-url https://sos-<zone>.exo.io):

aws s3api put-bucket-cors --bucket my-ddos --cors-configuration file://cors.json

MinIO handles CORS for the whole server rather than per bucket. It allows every origin by default; to restrict it to your app, set the allowed origins (comma separated) on the server:

# docker / environment
MINIO_API_CORS_ALLOW_ORIGIN="https://app.example.com,http://localhost:5173"
 
# or at runtime, with mc
mc admin config set myminio api cors_allow_origin="https://app.example.com"
mc admin service restart myminio

The CORS rules apply to endpoint, which the browser uploads to. nodeEndpoint needs none: only the node reads from it, server to server.

IPFS

  • Kubo does not answer CORS on its RPC API by default. Allow your origin and POST (every Kubo RPC call is a POST, add and pin/rm included), then restart the daemon:

    ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["https://app.example.com"]'
    ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["POST"]'

    The RPC API is an admin interface: never expose it to the internet, browser app or not.

  • Pinning services decide CORS themselves; check that yours allows browser uploads and DELETE for unpinning, and read the warning above before putting its key in a web app.

  • The gateway for gatewayUrl (verify()) must allow GET from your origin. Public gateways usually send Access-Control-Allow-Origin: *; a private one may need configuring. Without gatewayUrl, a Kubo store's verify() calls /api/v0/cat on the RPC API, which the Kubo CORS settings above cover.

Apart from the stores, the publish path itself still needs Node's crypto and Buffer (envelope hashing and signing), as before, so a bundler must provide them for browser publishing.

Not for DDOs: the node's own storage

NodePersistentRemoteStore writes to ocean-node's persistent storage (buckets). It cannot hold DDOs on ocean-node 4.2: the node resolves a remote DDO without a consumer address, and a bucket refuses that read, so the asset would go on chain and never be indexed (see how the node resolves your DDO). publish(), completePublish() and edit() therefore reject it, before any transaction, with an error that points to IpfsRemoteStore.

The class still works as a plain bucket uploader — see buckets — and buckets still work for an asset's files (nodePersistentStorage in addFile).

How the node resolves your DDO

This is what ocean-node 4.2 does with a nautilus publish or edit, condensed from the indexer code. nautilus produces exactly this format; you only need it to debug an asset that does not show up, or to write your own store.

What is stored

The store never receives the DDO, or even the signed credential, in clear. It receives a small JSON envelope whose only field is node ciphertext:

{"encryptedData":"0x04a1…"}

That ciphertext decrypts to {"encryptedData":"0x…"} again, whose value is the hex of the JSON-quoted compact JWS that signs the DDO. In pseudocode:

jws          = signer.sign(ddo)                                    // compact JWS
inner        = hexlify(utf8(JSON.stringify(jws)))                  // hex of the JSON-quoted JWS
envelope     = JSON.stringify({ encryptedData: node.encrypt({ encryptedData: inner }) })
metaDataHash = '0x' + sha256(envelope)                             // lowercase hex
pointer      = remoteStore.put(envelope, { did })                  // e.g. { type: 'ipfs', hash }
on chain     : setMetaData(state, nodeUri, '', 0x02, node.encrypt({ remote: pointer }), metaDataHash)

This is the same format the enterprise market produces with encryptAsset: true.

What the node checks

When the indexer sees a MetadataCreated or MetadataUpdated event:

  1. Lifecycle state. If the NFT is DEPRECATED (2) or REVOKED_BY_PUBLISHER (3), the node does not index the new metadata and stops. What it keeps depends on the node's version and database: ocean-node 4.2.x with Elasticsearch drops the stored DDO (404); with Typesense it keeps a short document holding only the NFT state. This is one-way — see lifecycle states.
  2. Pointer. Flags must be 0x02. The node asks the node named in the event (nodeUri) to decrypt the pointer, and the plaintext must be { "remote": <StorageObject> } with exactly one key.
  3. Fetch. It resolves the pointer through its storage layer — without a consumer address — and reads the raw stored bytes.
  4. Hash. It parses them as JSON and compares 0x + sha256(JSON.stringify of that object) with the on-chain metaDataHash. A body that is not JSON fails here.
  5. Envelope. It decrypts encryptedData, decodes the hex, and expects a compact JWS.
  6. DID. The JWS payload's id must equal did:ope: + sha256(checksummed NFT address + chain id), and its version must be 5.0.0. The signature is stored, not verified.
  7. Update rules. Only the first metadata event of an asset in a block is processed; a second one in the same block is ignored.
  8. Database write. The DDO is validated against the DDO v5 SHACL schema.

Because the envelope has a single string-valued key, parsing and re-serializing it gives the same bytes back, so the hash still holds if a store or gateway adds whitespace.

Three rules that follow

  • Same node. The pointer and the envelope are encrypted with keys that exist only on the node that encrypted them, and the indexer asks the node written on chain (nodeUri) to decrypt. nautilus writes the oceanNodeUri it encrypted with. If that node is gone, or restricts who may decrypt (authorizedDecryptersList), other nodes cannot index the asset.
  • One metadata event per asset per block. A Nautilus instance runs its own writes to one NFT one after the other, so they cannot collide. A write from another instance or process can, and nautilus then throws a MetadataConflictError after the receipt — see editing.
  • Errors are not on chain. Both transactions succeed even when indexing fails. The node records the outcome in its indexing state, GET <node>/api/aquarius/state/ddo, which nautilus reads for you: waitForIndexer throws an IndexingError with the node's message, and getIndexingState returns the raw record. A failure is filed under the old did:op: id with the nft and txId — query ?txId= — and stays after a later success; a success is filed under the did:ope: DID — query ?did=.

PublishResponse.stored returns the pointer (redacted: secrets read '<redacted>') and the hash, so you can verify what was stored, or unpin it later. In a url pointer that means the user name and password, every query value (the parameter names stay) and the fragment; scheme, host and path stay.

Storage options

The pointer is any ocean-node file object, so the envelope can live anywhere the node can read a file from without a consumer address. nautilus ships IpfsRemoteStore and S3RemoteStore; for the others, write your own store that returns the pointer type.

typePointerWorks on 4.2Node needsCaveats
ipfs{ type, hash }Yes — the recommended default (IpfsRemoteStore)IPFS_GATEWAYThe CID is public, but the content is ciphertext. Unpin to clean up. The gateway must be able to fetch the content (30 s timeout).
url{ type, url, method: 'GET', headers? }YesURL must not match UNSAFE_URLSAny HTTPS host, e.g. a public-read object in a bucket. Headers end up inside the encrypted pointer. Do not use expiring pre-signed URLs: re-indexing would fail once they expire.
s3{ type, s3Access: { endpoint, bucket, objectKey, accessKeyId, secretAccessKey, region?, forcePathStyle? } }Yes — supported natively (S3RemoteStore)—Works with S3-compatible stores (forcePathStyle: true for MinIO and other path-style stores). There is no anonymous read: the pointer always carries a key pair, so the credentials end up inside the on-chain pointer — node-encrypted, but readable by whoever holds the node key, and permanent. Upload with a separate write key, and put a read-only key scoped to the DDO prefix in the pointer. Rotating that key breaks re-indexing of older assets. See S3.
arweave{ type, transactionId }YesARWEAVE_GATEWAYPermanent: it can never be cleaned up. Not for test data.
ftp{ type, url }Technically—Credentials in the URL. Not recommended.
nodePersistentStorage{ type, bucketId, fileName }No—Buckets require a consumer address, and the node does not pass one when it resolves a DDO. nautilus rejects this pointer.

Writing your own

The interface is one required method, put, and three optional ones: check, verify, and remove for cleanup (nautilus calls remove only for an envelope whose metadata transaction was never sent, or mined and reverted; see when a write fails).

class  implements RemoteStore {
  async (: string, : { : string }): <> { 
    // Store `payload` byte for byte: its sha256 is the on-chain metadata hash.
    const  = await (, `${.}.json`)
 
    return { : 'url', , : 'GET' } as 
  }
 
  async (): <void> { 
    // Throw here if put() is going to fail: bad credentials, an unreachable endpoint.
    await ()
  }
 
  async (: , : string): <void> { 
    // Read it back as the node will, and hash it as the node does.
    const  = await ()
    const  = await (.(.()))
    if (`0x${}` !== ) throw new ('the store changed the envelope')
  }
}
 
declare function (: string, : string): <string>
declare function (: ): <string>
declare function (: string): <string>
declare function (): <void>

The contract:

  • put() receives the envelope, a JSON string {"encryptedData":"0x…"} — not the signed DDO.
  • Store it unchanged and serve it back as is. The node hashes what it fetches (after a JSON.parse/JSON.stringify round trip, so whitespace does not matter) and compares it with the on-chain hash, so wrapping it, re-encoding it or adding fields breaks indexing.
  • Return a pointer of a supported type — ipfs, url, s3, arweave or ftp, from the table above — that the node can fetch without a consumer address. Return the plain storage object, not one already wrapped in { remote }. An ipfs pointer's hash must be a CIDv0 or CIDv1.
  • remove(), if you implement it, must accept the redacted pointer from PublishResponse.stored: secrets in it read '<redacted>'. For a url pointer, work from the host and path: the user info, every query value and the fragment are redacted.

nautilus checks every pointer, custom stores included, before anything goes on chain. It rejects nodePersistentStorage, any other unknown type (matched case-insensitively), a pointer missing what its type's storage class needs (a url without an absolute http(s) url or a GET/POST method, or with non-string headers; an s3 without non-empty bucket, objectKey, endpoint, accessKeyId and secretAccessKey; an arweave transactionId that is a URL or path; an ftp url that is not ftp:///ftps://), and a pointer that is not a plain JSON object or is already wrapped in { remote }; the rest it wraps in { remote } and has the node encrypt. On publish(), a rejected pointer surfaces as a PublishIncompleteError, because the NFT already exists by then.

hint.did is a stable name, for stores that need a key.

check() runs before the first transaction of a publish(), completePublish() or edit(). Implement it whenever your store can tell up front that it will fail. verify() runs right before the metadata transaction, with the unredacted pointer put() returned; implement it when you can read the object back the way the node will. Resolve only after that read: if a configuration leaves no way to read back, throw (and say so in check()) rather than resolve, unless the user opted out explicitly.

Overriding per call

The instance's store is the default; a single publish can use a different one.

await .(, {
  : new ({ : 'http://127.0.0.1:5001/api/v0/add' }) 
})