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.
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:
probe | What 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. |
| omitted | No 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:
| Configuration | How |
|---|---|
gatewayUrl | GET <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 gatewayUrl | POST /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 gatewayUrl | None: check() throws before anything is minted. Set gatewayUrl, or opt out with verify: false. |
verify: false | None, 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
- A bucket, private, with a prefix for DDOs, e.g.
ddo/. - A write key for the publisher, allowed to
s3:PutObjectands3:DeleteObjectunder the prefix. It never leaves your machine. - A read-only key scoped to the prefix (
s3:GetObjectonly). It goes into the node-encrypted on-chain pointer of every asset, forever. 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 explicit403 AccessDeniedcounts as "cannot" — and deletes the probes. It does not try version-level actions, so on a versioned bucket make sure the read key has nos3:DeleteObjectVersioneither.verify()reads every stored envelope back with the read key before the metadata transaction.- Endpoints.
endpointis where you upload, and must behttps://unless it is a loopback host or you passallowInsecureTransport: true.nodeEndpointis what the node is told, when it reaches the bucket differently (127.0.0.1:9000for you,minio:9000inside Docker); it defaults toendpointand may behttp://, since only the node connects to it. The pointer gets it with an explicit, lowercase scheme (none meanshttps://). Signed requests never follow a redirect; S3 redirects whenregionorendpointdoes not match the bucket, and the error says so. - Addressing.
forcePathStyle: truefor MinIO and other path-style stores; the default is virtual-host style (bucket.endpoint/key). On an IP address orlocalhostendpoint virtual-host style cannot work, so the constructor throws until you setforcePathStyle(ornodeForcePathStylefornodeEndpoint). A bucket name with dots needsforcePathStyle: trueoverhttps://, sincebucket.with.dots.hostdoes not match the endpoint's certificate. The bucket name must follow the S3 naming rules. - Keys.
prefixmust not contain empty,.or..segments, a leading/or a backslash. - 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 failure | The transaction | The stored object | error.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 transaction | never sent, or changed nothing | removed with the store's remove() | 'removed' |
the same, but the store has no remove() or it failed | nothing points at it | left | '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 them | never mined | removed 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 mined | kept | '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 mined | kept | 'kept', with txHash when known |
after the receipt (waitForIndexer, MetadataConflictError) | mined | kept, 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:
| Origins | your app's origin, e.g. https://app.example.com |
| Methods | PUT (upload, check()), GET (verify(), check()), DELETE (remove(), check()) |
| Headers | authorization, 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.jsonMinIO 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 myminioThe 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 aPOST,addandpin/rmincluded), 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
DELETEfor unpinning, and read the warning above before putting its key in a web app. -
The gateway for
gatewayUrl(verify()) must allowGETfrom your origin. Public gateways usually sendAccess-Control-Allow-Origin: *; a private one may need configuring. WithoutgatewayUrl, a Kubo store'sverify()calls/api/v0/caton 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:
- 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.
- 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. - Fetch. It resolves the pointer through its storage layer — without a consumer address — and reads the raw stored bytes.
- Hash. It parses them as JSON and compares
0x+ sha256(JSON.stringifyof that object) with the on-chainmetaDataHash. A body that is not JSON fails here. - Envelope. It decrypts
encryptedData, decodes the hex, and expects a compact JWS. - DID. The JWS payload's
idmust equaldid:ope:+ sha256(checksummed NFT address + chain id), and itsversionmust be5.0.0. The signature is stored, not verified. - Update rules. Only the first metadata event of an asset in a block is processed; a second one in the same block is ignored.
- 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 theoceanNodeUriit 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
Nautilusinstance 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 aMetadataConflictErrorafter 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:waitForIndexerthrows anIndexingErrorwith the node's message, andgetIndexingStatereturns the raw record. A failure is filed under the olddid:op:id with thenftandtxId— query?txId=— and stays after a later success; a success is filed under thedid: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.
type | Pointer | Works on 4.2 | Node needs | Caveats |
|---|---|---|---|---|
ipfs | { type, hash } | Yes — the recommended default (IpfsRemoteStore) | IPFS_GATEWAY | The 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? } | Yes | URL must not match UNSAFE_URLS | Any 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 } | Yes | ARWEAVE_GATEWAY | Permanent: 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.stringifyround 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,arweaveorftp, from the table above — that the node can fetch without a consumer address. Return the plain storage object, not one already wrapped in{ remote }. Anipfspointer'shashmust be a CIDv0 or CIDv1. remove(), if you implement it, must accept the redacted pointer fromPublishResponse.stored: secrets in it read'<redacted>'. For aurlpointer, 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' })
})