本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント・一次情報と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
前回、Pontus-X の公開カタログを curl で覗くで、Gaia-X 準拠のフェデレーテッド・データエコノミーである Pontus-X のカタログを外から叩きました。叩いた先は Aquarius という HTTP API です。
ここで一点、用語の整理をしておきます。Aquarius は「規格」ではなく、Ocean Protocol が用意した実装部品の名前です。DCAT(Data Catalog Vocabulary)や DID(Decentralized Identifier、分散識別子)のような共通仕様ではないので、「Aquarius に対応する」という言い方は成立しません。Pontus-X が Ocean のスタックで組まれているから、そこに Aquarius が居るだけです。
では、その実装部品は手元で動かせるのか。動かしてみた記録が本記事です。結論を先に書くと、ノード自体は数分で立ち上がり、12 分ほどでチェーンの現在高に追いつきました。しかし Pontus-X のアセットは 1 件も取り込めませんでした。 切り分けた範囲では、原因は権限ではなく、Ocean Node と Pontus-X が運用している Provider の間で、復号を依頼するときの署名対象の文字列が食い違っていることのようでした。
数値・出力はすべて 2026-08-05 時点、oceanprotocol/ocean-node:latest(3.2.0)で確認したものです。
建てるのは 1 プロセスだけ
以前の Ocean は Aquarius(メタデータの索引)、Provider(データ配信と暗号化・復号の窓口)、subgraph(オンチェーン事象の集計)が別々のサービスでした。Ocean Protocol の実装としては、現在これらが Ocean Node 1 つに統合されています(後述するとおり、Pontus-X が公開しているエンドポイントでは従来構成の Aquarius が動いています)。
なので手元に用意するのは Ocean Node と、その保存先(Typesense か Elasticsearch)だけです。Docker と Docker Compose があれば足ります。
なお、リポジトリには npm run quickstart という対話式のセットアップスクリプトがあります。ただし、それが生成する docker-compose.yml には後述の DB_TYPE が含まれていないため、ここでは自分で書きました。
何をインデックスさせるか
Ocean Node は「どのチェーンを巡回するか」を RPCS 環境変数で受け取り、そのチェーンの ERC721Factory が出すイベントを追いかけます。今回は Pontus-X testnet(chainId 32457)を対象にしました。RPC(remote procedure call)の接続先は、公式ドキュメントの Quick Links にある https://rpc.test.pontus-x.eu です。
curl -s -X POST https://rpc.test.pontus-x.eu -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'
{"jsonrpc":"2.0","id":1,"result":"0x7ec9"}
0x7ec9 = 32457 で、testnet につながっていることが確認できます。
最終的な docker-compose.yml
先に完成形を置きます。この形に至るまでに 3 か所つまずいたので、それぞれ後述します。
services:
ocean-node:
image: oceanprotocol/ocean-node:latest
container_name: ocean-node
restart: on-failure
ports:
- "8001:8001"
- "9000:9000"
- "9001:9001"
environment:
PRIVATE_KEY: '${PRIVATE_KEY}'
RPCS: '{"32457":{"rpc":"https://rpc.test.pontus-x.eu","chainId":32457,"network":"pontus-x-testnet","chunkSize":1000,"startBlock":11300000}}'
INDEXER_NETWORKS: '[32457]'
INDEXER_INTERVAL: '10000'
ADDRESS_FILE: '/usr/src/app/address.json'
DB_TYPE: 'typesense'
DB_URL: 'http://typesense:8108/?apiKey=xyz'
IPFS_GATEWAY: 'https://ipfs.io/'
ARWEAVE_GATEWAY: 'https://arweave.net/'
HTTP_API_PORT: '8001'
INTERFACES: '["HTTP"]'
LOG_LEVEL: 'info'
LOG_CONSOLE: 'true'
P2P_ENABLE_IPV4: 'true'
P2P_ENABLE_IPV6: 'false'
P2P_ipV4BindAddress: '0.0.0.0'
P2P_ipV4BindTcpPort: '9000'
P2P_ipV4BindWsPort: '9001'
P2P_ENABLE_UPNP: 'false'
P2P_ENABLE_CIRCUIT_RELAY_SERVER: 'false'
P2P_ENABLE_CIRCUIT_RELAY_CLIENT: 'false'
volumes:
- ./address.json:/usr/src/app/address.json:ro
- node-sqlite:/usr/src/app/databases
networks: [ocean_network]
depends_on: [typesense]
typesense:
image: typesense/typesense:26.0
container_name: ocean-typesense
restart: on-failure
ports:
- "8108:8108"
command: '--data-dir /data --api-key=xyz'
volumes:
- typesense-data:/data
networks: [ocean_network]
volumes:
typesense-data:
node-sqlite:
networks:
ocean_network:
driver: bridge
PRIVATE_KEY はノードの identity に使われる鍵で、残高もガスも必要ありません。使い捨てを生成して .env に置き、コミット対象から外しています。
node -e "console.log('PRIVATE_KEY=0x'+require('crypto').randomBytes(32).toString('hex'))" > .env
chmod 600 .env
つまずき 1: DB_TYPE を書かないと Indexer が丸ごと無効になる
最初に起動したとき、ログにこう出ました。
DATABASE: Invalid DB URL. Only Nonce, C2D, Auth Token and Config Databases are initialized.
OCEANNODE: Missing or invalid property: "DB_URL". This means Indexer module will not be enabled.
Typesense 自体は健全で、curl http://localhost:8108/health は {"ok":true} を返します。DB_URL の綴りも合っています。ソースを追うと、設定の組み立て時に既定値が入っていました。
// src/utils/config/builder.ts
dbType: data.DB_TYPE || 'elasticsearch'
そして検証側は、Elasticsearch のときだけ user/pass を必須にしています。
// src/utils/database.ts
if (configuration.dbType === DB_TYPES.ELASTIC_SEARCH) {
return !!(configuration.username && configuration.password)
}
つまり DB_TYPE を省略すると、実際には Typesense を指していても Elasticsearch 扱いになり、user/pass が無いので設定が無効と判定され、Indexer が起動しません。DB_TYPE: 'typesense' を明示すると通りました。
このキーは、調べた限り docs/env.md に記載がなく、公式の scripts/ocean-node-quickstart.sh が生成する docker-compose.yml(保存先は Typesense)にも含まれていませんでした。
つまずき 2: testnet の ERC721Factory アドレスが address.json に入っていない
Ocean Node は @oceanprotocol/contracts に同梱された addresses/address.json から、チェーンごとの ERC721Factory アドレスと開始ブロックを読みます。ところがこのファイルに Pontus-X は devnet(32456)しか入っていません。
curl -s https://raw.githubusercontent.com/oceanprotocol/contracts/main/addresses/address.json | python3 -c "import sys,json; d=json.load(sys.stdin); print([(k,v.get('chainId')) for k,v in d.items() if 'pontus' in k])"
[('pontus-x-devnet', 32456)]
deltaDAO 側のフォーク(deltaDAO/contracts)も同様で、testnet(32457)のエントリはありませんでした。ADDRESS_FILE 環境変数で差し替えられる作りなので、自分で用意します。
アドレスはチェーンから逆引きできます。公開カタログから testnet のアセットを 1 件取り、その nftAddress に関わる最初のログのトランザクションを引くと、to に data NFT(non-fungible token、データセットの所有を表すトークン)を発行した ERC721Factory が入っています。以下は擬似コードで、カタログは前回の記事と同じ https://aquarius.pontus-x.eu/api/aquarius/assets/query を使っています。
# 1. 公開カタログから testnet のアセットを取る → nftAddress と、metadata イベントのブロック
# 2. そのブロックの手前 1,900 ブロックを eth_getLogs で見る(RPC 側の上限が 2,000 のため)
# 3. 最初のログの transactionHash を eth_getTransactionByHash に投げ、to を見る
logs = post(RPC, {"method": "eth_getLogs", "params": [{"address": nft, "fromBlock": hex(blk-1900), "toBlock": hex(blk)}], ...})
tx = post(RPC, {"method": "eth_getTransactionByHash", "params": [logs[0]["transactionHash"]], ...})
print(tx["result"]["to"])
publisher の異なるアセットで試すと、いずれも同じアドレスが返りました。以下は新しい順に 6 件の結果です。
nft=0xe449AcA1195d57871C43D2FA0468D2523654fBcE to=0x2c4d542ff791890d9290eec89c9348a4891a6fd2 publisher=0xa3df71e9
nft=0x44DB478D4c83A91056602809B3d77F75bd6e0a4d to=0x2c4d542ff791890d9290eec89c9348a4891a6fd2 publisher=0xa3df71e9
nft=0x5B4e93764A0541c9B0C3FEA3eD9B082F1749aC97 to=0x2c4d542ff791890d9290eec89c9348a4891a6fd2 publisher=0xa3df71e9
nft=0x525698e7f8EDe1c593B4Fb9D4BF7E00203005e09 to=0x2c4d542ff791890d9290eec89c9348a4891a6fd2 publisher=0xa3df71e9
nft=0xc075A3Da6061d41dB67880A07Ef79D13f329fd5e to=0x2c4d542ff791890d9290eec89c9348a4891a6fd2 publisher=0x6f9c23ca
nft=0x27a02F2F63F9301F047d8120F5a225aC23840427 to=0x2c4d542ff791890d9290eec89c9348a4891a6fd2 publisher=0x6f9c23ca
この手順には適用範囲があります。metadata イベントが data NFT の生成から 1,900 ブロック以内にあるアセットでないと、ERC721Factory は返りません。 後からメタデータを更新したアセットだと、拾われるのは NFT に直接打たれた setMetaData のトランザクションで、to には NFT 自身のアドレスが入ります。実際、2024 年公開の古いアセット 12 件で試すと、9 件が NFT 自身のアドレスを返しました。そのうちの 1 件は、NFT の生成が 340,513 ブロック目、metadata イベントが 1,119,512 ブロック目で、77.9 万ブロック離れていました。追試の際は、公開直後のアセットを選んでください。
得られたアドレスが本当に ERC721Factory かどうかは、コントラクトに直接聞けば確かめられます。getCurrentNFTCount() が 3,843、getCurrentTemplateCount() が 2、router() がアドレスを返したので、ERC721Factory の ABI と合致します。デプロイは 82,245 ブロック目でした(その 1 つ前のブロックではコードが空)。
これを address.json に足して ADDRESS_FILE で読ませます。以下は追加したエントリだけの抜粋です。
{
"pontus-x-testnet": {
"chainId": 32457,
"ERC721Factory": "0x2c4d542ff791890d9290eec89c9348a4891a6fd2",
"startBlock": 2500000
}
}
ADDRESS_FILE は同梱の address.json に追記されるのではなく、ファイルごと差し替わります。他のチェーンも扱うなら、既存の address.json をダウンロードしてから追記してください。ここの startBlock は下限としてのみ使われるので(コード上の deployBlock)、次節の RPCS 側の値がこれを上回るように選べば足ります。上の 2,500,000 は、デプロイブロックを特定する前に置いた値です。
つまずき 3: 全履歴は現実的でないので開始ブロックをずらす
testnet の現在高は約 1,173 万ブロックです。工場のデプロイ(82,245 ブロック目)から素直に追うと 1,000 ブロックずつ 1 万回以上の巡回になり、手元で試す規模ではありません。RPCS 側の startBlock は、address.json 側の startBlock(コード上の deployBlock)より大きければ優先されるので、ここで直近だけに絞りました。
// src/components/Indexer/ChainIndexer.ts(indexLoop 内)
const crawlingStartBlock =
this.rpcDetails.startBlock && this.rpcDetails.startBlock > contractDeploymentBlock
? this.rpcDetails.startBlock
: contractDeploymentBlock
今回は 11,300,000 を指定しました。Pontus-X testnet のブロック生成間隔は実測で 5.69 秒だったので、現在高からおよそ 4 週間ぶん(43 万ブロック)さかのぼった位置になります。
chunkSize は 1000 にしました。この RPC は 1 回の eth_getLogs で照会できるブロック範囲に上限(エラー文では rounds)があり、2,000 を超えるとエラーになるためです。
{"code": -32000, "message": "invalid request: max allowed of rounds in logs query is: 2000"}
起動する
docker compose up -d
ノードは立ち上がり、指定したブロックから巡回を始めました。以下は追いつく途中の画面です。

43 万ブロックぶんの遅れは 11 分 35 秒で解消し、現在高に追いつきました(巡回の速度は一定ではなく、メタデータのイベントが多い区間ほど遅くなります)。ログには data NFT の作成やメタデータ更新のイベントが次々に流れます。
INDEXER: -- MetadataCreated -- triggered for 0xad1c443a97a07ab33d0ef58bed5fad19...
INDEXER: -- DispenserCreated -- triggered for 0x976e1a9ce054704cf48a531f6ea34591...
ところが、保存先を見るとアセットが 1 件もありません。
curl -s http://localhost:8108/collections -H 'X-TYPESENSE-API-KEY: xyz' | jq -r '.[] | "\(.name)\t\(.num_documents)"'
access_list 0
state 203
order 0
logs 0
indexer 1
op_ddo_v4.7.0 0
op_ddo_v4.5.0 0
op_ddo_v4.3.0 0
op_ddo_v4.1.0 0
op_ddo_short 0
state(取り込み状態の記録)には 203 件、つまり検出した DID の数だけ入っているのに、DDO(DID Document、アセットのメタデータ本体)が入る op_ddo_* はすべて 0 です。state の中身を見ると、203 件すべてが valid: false で、同じエラーで失敗していました。
{
"chainId": 32457,
"did": "did:op:4e1da490453d11b1e6517d033754e029e9d84927f188017dc8b1d4d4e8a077d1",
"error": "Provider exception on decrypt DDO. Status: Provider validation failed: BAD REQUEST",
"valid": false
}
どこで止まっているのか
Pontus-X の DDO はチェーン上に暗号化された状態で置かれていて、読むにはそれを暗号化した Provider に復号を依頼する必要があります。依頼先は DDO のイベントに記録されていて、今回は https://provider.test.pontus-x.eu です。ローカルノードのログには、依頼して 400 が返るまでが素直に出ていました。
Decrypting DDO from network: 32457 created by: 0xBA87B2E7... encrypted by: https://provider.test.pontus-x.eu
decryptDDO: Making HTTP request for nonce. DecryptorURL: https://provider.test.pontus-x.eu
decryptDDO: Fetched fresh nonce 2 for decrypt attempt
Decrypt request successful. Status: 400, BAD REQUEST
Provider exception on decrypt DDO. Status: Provider validation failed: BAD REQUEST
最初に疑ったのは権限です。Provider には復号できるアドレスを制限する仕組みが実際にあり、許可リストに載っていなければ 403 を返します。
# ocean_provider/routes/decrypt.py
authorized_decrypters = decode_keyed("AUTHORIZED_DECRYPTERS")
if authorized_decrypters and decrypter_address not in authorized_decrypters:
return error_response("Decrypter not authorized", 403, logger)
ただし返ってきたのは 403 ではなく 400 で、しかもメッセージは署名についてのものでした。
そこでソースを読みました。Ocean Node が署名しているのは、自分のアドレスと nonce とコマンド名をつないだ文字列です。
// src/components/Indexer/processors/BaseProcessor.ts
const message = String(String(ethAddress) + String(nonce) + String(PROTOCOL_COMMANDS.DECRYPT_DDO))
const signature = await keyManager.signMessage(message)
この signMessage は生の文字列に署名するのではなく、いったん keccak256 でハッシュしてから署名します。
// src/components/KeyManager/providers/RawPrivateKeyProvider.ts
const messageHash = ethers.solidityPackedKeccak256(['bytes'], [ethers.hexlify(ethers.toUtf8Bytes(message))])
const signature = await wallet.signMessage(ethers.getBytes(messageHash))
一方、依頼先である Provider(Python 実装)の検証規則はこうなっています。
# ocean_provider/validation/provider_requests.py
"signature": ["bail", "decrypt_signature:transactionId,dataNftAddress,decrypterAddress,chainId,nonce"]
# → original_msg = f"{transactionId}{decrypter_address}{chain_id}" に nonce を足して keccak し、
# Ethereum の署名プレフィックスを付けてから検証する
dataNftAddress はリクエスト本体には含めますが、transactionId がある限り署名対象の文字列には入りません。
つまり、ハッシュの手順(keccak256 してから Ethereum の署名プレフィックスを付ける)は両者で同じで、違うのは署名対象の文字列だけです。そこで、Ocean Node の署名手順をそのまま書き写したスクリプトと、Provider が期待する文字列で署名し直したスクリプトを用意し、同じ鍵・同じアセットに対して依頼してみました。

前者はノードのログと同じ 400 になり、後者は 201 CREATED で DDO が返ってきました。使ったのはその場で作った残高ゼロの鍵なので、許可リストに載っているはずがありません。それでも通ったということは、この Provider では許可リストが設定されていない運用のようです。
少なくとも権限では弾かれていません。止まっていた直接の原因は、署名対象の文字列の食い違いでした。前回の記事で「カタログのメタデータは認証なしで誰でも取得できる」と書きましたが、暗号化された DDO についても、今回試した testnet の 1 件では同じことが言えました。
DDO のどこが決まっていて、どこが自由か
復号して中身が見えたので、ついでに整理しておきます。DDO は「Ocean の仕様で形が決まっている部分」と「発行者が好きに書ける部分」が層になっていて、どこに何を書くかはこの区別で決まります。
仕様で必須の部分
DDO Specification では、トップレベルは @context / id / version / chainId / nftAddress / metadata / services が必須で、credentials だけが任意です。その下は次のようになっています。
| 必須 | 任意 | |
|---|---|---|
metadata | created / updated / name / description / type / author / license | copyrightHolder / links / contentLanguage / tags / categories / additionalInformation |
services[] | id / type / datatokenAddress / serviceEndpoint / files / timeout | name / description / consumerParameters / additionalInformation |
値が限定されている箇所が 2 つあります。metadata.type は dataset か algorithm のどちらか、services[].type は access / compute / wss のどれかです。ここは増やせないので、たとえば「IIIF マニフェスト」のような独自の資源種別を持ち込みたい場合でも、type は dataset にしたうえで別の場所に書くことになります。
自由な部分
自由に書けるのは実質的に additionalInformation です。仕様の説明も「発行者がカスタマイズできる」とあるだけで、中身の形は決まっていません。tags と categories も自由ですが、こちらは文字列の配列という形だけは決まっています。
データスペースが上に載せる取り決め
Pontus-X の実際の DDO を見ると、その additionalInformation の下に Gaia-X 向けの区画が作られていました。
"additionalInformation": {
"gaiaXInformation": {
"containsPII": false,
"termsAndConditions": [{ "url": "" }],
"serviceSD": {
"url": "https://euprogigant.infotec-ag.de/serviceoffering/3008137-1731401375097.json",
"isVerified": true
}
}
}
containsPII(個人情報を含むか)、termsAndConditions(利用条件)、serviceSD(Gaia-X の Self-Description の所在と検証済みフラグ)はいずれも Ocean の仕様には出てきません。Ocean が「自由に書いてよい」と空けた場所に、データスペース側が自分たちの取り決めを置いているという構造です。他のデータスペースで同じ Ocean スタックを使っても、ここに何を書くかは揃いません。
保存・検索側はさらに緩い
取り込む側の制約も見ておきます。ローカルノードが使う Typesense のスキーマは、ocean-node のリポジトリではこうなっていました。
{
"name": "op_ddo_v4.5.0",
"enable_nested_fields": true,
"fields": [{ "name": ".*", "type": "auto", "optional": true }]
}
全フィールドがワイルドカードで、型は自動判定、すべて任意です。つまり保存層は形を一切強制しません。
一方、Elasticsearch を使う場合のマッピング(@oceanprotocol/ddo-js)では、metadata の下の created / name / type / license / tags などが個別に定義されていて、additionalInformation だけが {"type": "object", "enabled": false} になっています。格納はするが索引しない、という指定です。
ただしこれは ocean-node 側の話で、Pontus-X が公開している Aquarius(5.1.5)では additionalInformation の下も検索できました。
curl -s -X POST https://aquarius.pontus-x.eu/api/aquarius/assets/query -H 'Content-Type: application/json' -d '{"size":0,"query":{"term":{"metadata.additionalInformation.gaiaXInformation.containsPII":false}}}'
"total": {"relation": "eq", "value": 3547}
まとめると、独自のメタデータを載せる場所は additionalInformation しかなく、そこが検索できるかどうかは、相手が動かしているインデックス実装しだいということになります。仕様で決まっているのは必須項目と 2 つの enum までで、その先はデータスペースごとの取り決めと運用に委ねられています。
公開 Aquarius と自前ノードは API も別物
もう一つ、外から叩くときとの違いです。Pontus-X が公開している Aquarius(5.1.5)と、手元に建てた Ocean Node(3.2.0)では、検索 API の形が揃っていません。
| 公開 Aquarius | ローカルの Ocean Node | |
|---|---|---|
| 検索のパス | /api/aquarius/assets/query | /api/aquarius/assets/metadata/query |
| リクエスト | Elasticsearch のクエリ DSL(match_all も可) | query.bool を含む形(または filter_by を含む Typesense ネイティブ形式) |
| レスポンス | hits.total / hits.hits の入れ物 | 配列をそのまま返す |
| 1 件取得 | /api/aquarius/assets/ddo/{did} | /api/aquarius/assets/ddo/{did} |
前回の記事で使った {"query":{"match_all":{}}} をローカルノードに投げると 500 が返り、ログには Cannot read properties of undefined (reading 'filter') が出ます(query.bool が無いため)。bool を含む形にすると 200 になりました(中身は空なので [] です)。
curl -s -X POST http://localhost:8001/api/aquarius/assets/metadata/query -H 'Content-Type: application/json' -d '{"query":{"bool":{"filter":[{"term":{"chainId":32457}}]}}}'
[]
ちなみに docs/API.md はこのエンドポイントを Typesense 形式(q と query_by)で説明していますが、3.2.0 に対してその形で投げた範囲では 500 が返りました(query キー自体が無いので、今度は reading 'bool' で落ちます)。実装とドキュメントで受け付ける形が異なっているようです。「Aquarius 互換」と一括りにはできない、という意味でも、Aquarius は規格ではなく実装だと分かります。
ここまでで言えること
- 起動そのものは短時間で済みました。Docker と Docker Compose があれば、
docker compose up -dと設定ファイル 2 つで、チェーンを巡回して HTTP API を提供するノードが動きます。ブロックチェーンを扱う割に、ウォレットの残高もガス代も要りません。 - Pontus-X のカタログを自前で持つのは、現時点では別の話です。上記の食い違いがある限り、
oceanprotocol/ocean-node:latestをそのまま向けても DDO は 1 件も入りません。公開の Aquarius を叩く(前回の記事の方法)ほうが確実です。 - ドキュメント以外を読んで分かった前提がいくつかありました。
DB_TYPEの既定値、testnet のコントラクトアドレス、検索 API の書式の違いは、いずれもコードとチェーンから確認しています。
確認できなかったこととして、この食い違いが Pontus-X が運用している Python 実装の Provider の仕様に由来するのか、Ocean Node 側の変更によるものなのかは判断していません。また、確かめたのは 1 つの Provider・1 件のアセット・1 つのバージョンなので、署名対象の文字列以外に不一致が残っていないとまでは言えません。Ocean Node どうし(新しい実装で暗号化された DDO を、新しい実装のノードが復号する)であれば通るはずですが、そこも手元で確かめていません。
再現用の一式(docker-compose.yml・address.json・切り分けに使った 2 本のスクリプト)は手元のリポジトリに置いてありますが、公開はしていません。201 を得た側の署名生成は次の 2 行です。追試される方は、コントラクトアドレスと巡回開始ブロックをご自身の環境に合わせてください。
const msg = `${transactionId}${decrypterAddress}${chainId}${nonce}`
const sig = await wallet.signMessage(ethers.getBytes(ethers.keccak256(ethers.toUtf8Bytes(msg))))
コメント
…