本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント・一次情報と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

データスペースのポータル(Ocean Protocol 系のカタログ画面)を、自前で建てた Ocean Node に繋いでみました。

結果から言うと、publish もダウンロードも計算も、最初は一つも通りませんでした。しかも出てくるエラーが毎回別物で、原因が見えない。

追い切ったところ、詰まりは大きく 2 種類に分かれました。

  • ライブラリの世代差 — 10 箇所。チェーンとは無関係
  • アプリ自身の不具合 — 9 系統。版とも無関係

そして後者のうち 2 件が、ローカルチェーンでは絶対に露見せず、実際のチェーンでだけ壊れるという質の悪いものでした。

本記事は、画面の操作だけで Compute-to-Data を完走させるまでの記録です。2026-08-10 に実際に動かした値だけを載せます。

到達点

先に結論を出しておきます。

段階実行者結果
publish所有者(CLI)Sepolia に記録 → 索引 → 検索 → 詳細表示
ダウンロード別の利用者(画面)IIIF Manifest 5,797 バイト
計算 C2D別の利用者(画面)totalAnnotations: 4
結果の受け取り別の利用者(画面)outputs.tar 2,560 バイト

この一連の流れは、後日あらためて録画しました。別記事の実演動画で、画面の多言語対応とあわせて見られます。

受け取ったのはこれだけです。

{
  "totalAnnotations": 4,
  "perFile": [
    { "file": "matsuyama-annotations.json",
      "label": "松山 団体旅行記録",
      "annotations": 4, "annotationPages": 2, "canvases": 1 }
  ]
}

データ本体は利用者に一度も渡っていません。 渡ったのは計算結果だけ。 そして通行条件は支払いではなく、アクセス権を表す NFT の保有でした。

前提: 登場人物は 4 つ

詰まりの話に入る前に、全体の形を書いておきます。

チェーン        誰が何を持っているかの台帳
  NFT ........ 資産の所有権
  datatoken .. 資産の利用権(1 枚持っていれば使える)
  Dispenser .. datatoken を無料で配る蛇口

Ocean Node      門番。ファイルの実体 URL を暗号化して預かり、
                条件を満たした人にだけ渡す/計算する

subgraph        チェーンの出来事の索引(価格や所有を高速に引く)

ポータル        画面

publish は 3 段階です。

① NFT + datatoken(+ Dispenser)を作る    → チェーンに書く
② ファイルの URL を Ocean Node に暗号化してもらう   ← 門番に鍵を預ける
③ 説明書(DDO)をチェーンに書く              → ノードと subgraph が索引

②が要点です。URL は暗号化されて誰にも見えず、Ocean Node だけが復号できます。だから利用者は URL を知らないまま、条件を満たしたときだけ中身に触れられます。

所有者がファイルのURLをOcean Nodeに預け、ノードがそれを暗号化して保管する。暗号化されたURLを含む説明書(DDO)だけがチェーンとsubgraphに載る。別アカウントの利用者はノードに依頼はできるが、チェーン上の説明書を見ても復号できない。

使う側は 2 経路あります。

流れ認可の主体
ダウンロード型datatoken を入手 → 注文 → ノードがファイルを渡すdatatoken の保有
計算型(C2D)支払いなし → コンテナで実行 → 結果だけ返るアクセス権 NFT の保有

ダウンロード型は、datatokenを1枚持つことを通行条件として、データ本体(IIIF Manifest 5,797バイト)が手元に来る。計算型は、アクセス権NFTを持つことを通行条件として、結果だけ(outputs.tar 2,560バイト)が返り、本体は渡らない。

詰まり その1: ライブラリの世代差

ポータルが使っているクライアントライブラリは 2023-10 公開のもの、繋いだノードは現行でした。メジャーで 5 世代ぶんの開きがあります。

出てきた症状はバラバラでしたが、原因は 1 つです。

症状変わっていたところ
encrypt が 401認証パラメータが必須になった
initializeCompute が 400 Missing "payment"escrow 前提の課金 API になった
Invalid outputoutput が暗号化 hex 前提になった
Unable to extract docker imageアルゴリズムのコンテナ情報を要求するようになった
ダウンロードが signature mismatch署名対象が変わった
ジョブ一覧が空取得方法が変わった
成果物が 500 Invalid C2D Environment署名対象と ID の形式が変わった
No file info available が出続けるエンドポイント名の大文字小文字(後述)
計算環境の欄が N/A / undefinedCPU・RAM の項目が resources[] に移動した
使っているうちに全部 401 になるnonce が文字列で返るようになった(後述)

署名まわりは規則が統一されていて、こう変わっていました。

旧: documentId + nonce を署名
新: consumerAddress + nonce + <コマンド名> を署名
    (UTF-8 → solidityKeccak256(['bytes'], …) → personal_sign)

気づきにくかったもの: 同じノードの中で形が揃っていない

いちばん時間を取られたのがこれです。成果物の取得が 500 Invalid C2D Environment を返し続けました。

ジョブ開始の応答      : 0xff1004b6…-da4f153d…   (クラスタhash 付き)
ジョブ一覧の応答      : da4f153d…               (単体)
成果物 API が期待する形: 0xff1004b6…-da4f153d…   (クラスタhash 付き)

一覧が返す ID を、そのまま成果物 API に渡せません。 ノードは先頭のハイフンを探して切り出すので、単体形を渡すと存在しない環境を探しにいって 500 になります。

クライアント側でクラスタ hash を前置したら通りました。

拡張子ひとつで、エラーも出さずに失敗する

いちばん見つけにくかったのがこれです。資産のページに、最初から最後まで No file info available と出続けていました。

原因は大文字と小文字の違い 1 文字でした。

ノードが公開する名前 : fileInfo   (大文字 I)
ライブラリが探す名前 : fileinfo   (小文字)

ライブラリ側はこう書かれています。

const path = getEndpointURL(serviceEndpoints, 'fileinfo')
if (!path) return null      // ← リクエストを送らずに null

一致しないと、リクエストすら送りません。 例外も出ず、ログにも残らず、null が返るだけです。呼び出し側はそれを「ファイル情報が無い」と解釈して、静かに何も表示しません。

失敗が通信より前で起きているので、ネットワークタブを見ても何も出ません。ノードのログにも当然出ません。「ノード側の問題だろう」と何度も探しに行って、そのたびに空振りしました。

使うほど壊れて、最後に恒久的に死ぬ

もうひとつ、性質の悪いものがありました。

ノードの応答 : {"nonce":"26"}     ← 文字列

nonce文字列で返ります。ライブラリはこう使います。

((await this.getNonce(...)) + 1).toString()

JavaScript なので "26" + 1"261" です。足し算ではなく連結。

ノード側は parseInt(nonce) して「前回より大きいか」を見るので、しばらくは通ってしまいます。26 → 261 → 2611 → 26111 と、呼ぶたびに桁が 1 つ増えていく。

壊れ方は、当初私が考えたものとは違いました。大小比較は破綻しません(毎回 10 倍になるので丸め誤差では逆転しない)。破綻するのは署名の照合の方です。

クライアントが署名する文字列 : "26111111111111111"
ノードが組み直す文字列       : String(parseInt(...)) = "26111111111111110"

ノードは parseInt した数値からメッセージを再構成するため、2^53 を超えた瞬間に署名が一致しなくなります。手元で数えたところ、連結 15 回目(17 桁)で破綻しました。しかも nonce は検証に成功したときしか更新されないので、以後は同じ長すぎる値を送り続けることになり、そのアドレスは二度と通りません

「最初は動いていたのに、しばらく使っていたら急に全部 401 になった」という壊れ方をします。原因を後から辿るのは、かなり難しいと思います。

世代差はチェーンと無関係

ここは切り分けとして重要です。Sepolia を選んだから起きたことではありません。

同じノードを別のチェーンの上に建てても、まったく同じ壁に当たります。逆に、古い世代の Provider に繋げば起きません。「自前の新しいノードに繋いだ日」に一斉に出てくる種類の差です。

詰まり その2: 実際のチェーンでだけ壊れる不具合

こちらが本題です。版とは無関係で、アプリ自身の作りの問題です。

(a) 取引を送っただけで、次へ進んでしまう

無料配布から datatoken を受け取り、それを使って注文する箇所です。

const dispenserTx = await dispenser.dispense(datatokenAddress, '1', accountId)
// ← await dispenserTx.wait() が無い
return await datatoken.startOrder(...)   // まだ受け取っていない

await dispenser.dispense() が返すのは「取引を送った」ところまでで、「datatoken を受け取った」ではありません。次の行はもう受け取り済みの前提で動きます。

結果はこうなります。

execution reverted: Not enough datatokens to start Order

ローカルチェーンは即座に採掘されるので、開発中は絶対に露見しません。 ブロック間隔のある実際のチェーン(今回は約 12 秒)でだけ、確実に失敗します。

(b)「支払いがないなら履歴もない」という前提

計算ジョブの履歴を組み立てる処理が、チェーン上の注文記録から出発していました。

const results = await fetchDataForMultipleChains(getComputeOrders, …)
// … 注文が 0 件なら
if (tokenOrders.length === 0) {
  return computeResult          // ← ここで打ち切り
}

ところが無償の計算実行は、注文をチェーンに一切作りません。支払いが無いので当然です。

そのため、ジョブは正常に完走しているのに履歴から消えます。画面には Your job started successfully! と出たあと No results found が並ぶ、という状態になります。

「動いていないのでは」と見えますが、実際にはノード側にジョブも成果物も存在していました。この 2 つがずれていると、成功を失敗と誤認します。

この 2 件が厄介な理由

どちらも既定設定の開発環境では再現しません。ローカルチェーンは既定で即時採掘(ブロック間隔を設定すれば再現します)、テストデータは有償で作りがち。実際のチェーンに、無償の資産を置いて、別のアカウントから触って、初めて出ます。

詰まり その3: 値が無いのに無条件に使う

数としてはこれが一番多く、9 件ありました。形はどれも同じです。

selectedAlgorithmAsset?.metadata?.algorithm.consumerParameters
//                                        ↑ ここで ?. が切れている

tagResults.concat(ownerResults?.filter(…))
//  ownerResults が undefined だと [undefined] ができる

job.inputDID[0]
//  ノードの応答に inputDID が無いと落ちる

href={`/asset/${did}`}
//  did が null だと /asset/null になる

いずれも「API の応答が空だった」「オプショナルなフィールドが無かった」だけで、画面全体が落ちます。React の Error Boundary まで飛ぶので、真っ白になるか、その区画がまるごと消えます。

特に効いた 1 件: 表示されるエラーが、原因ではない

ここは当初、私が誤って書いていた部分です。訂正しつつ残します。

最初はこう考えました。「ライブラリのエラー整形関数が引数を JSON.parse するので、プレーンテキストを渡すと例外になり、本来のメッセージが消える」。実際、コンソールにはこう出ます。

[getErrorMessage] error: SyntaxError: Unexpected token 'M', "Missing on"... is not valid JSON

しかし実装を読むと、この関数は例外を投げていませんでした

} catch (e) {
  LoggerInstance.error('[getErrorMessage] error: ', e)
  return err          // ← 元の文字列を返している
}

上のログ行はライブラリ自身の catch が出しているもので、その直後に本来のメッセージは返っています。私が「消える」と思ったのは別の場所でした。

実際に起きていたのはこうです。

① ノードが 400 と 'Missing one ( "payment" ) …' を返す
② ライブラリが JSON.parse に失敗し、SyntaxError をログに出す(が、文字列は返す)
③ 呼び出し側が catch して null を返す
④ その null を使った先で
   Cannot read properties of undefined (reading 'algorithm')
⑤ 画面にはこの ④ が出る

本当のメッセージは①でコンソールに出ています。 ただし数行上にあり、しかもすぐ隣に②の SyntaxError が並んでいるので、そちらが原因に見えます。画面に出るのは④です。

つまり問題は「原因が消える」ではなく、「表示されるエラーが原因ではない」でした。①を見つけるまで、②と④を交互に追いかけることになります。

念のため、パースに失敗したら元の文字列を返すラッパを入れましたが、ライブラリが既に同じことをしていたので、実質的には冗長です。私の見立て違いでした。

ついでに踏んだ、環境側の落とし穴

アプリの問題ではないものも書いておきます。同じ構成を組む人は踏みます。

無償枠は別勘定 — 有償枠が空いているのに Not enough available cpu が返り続けました。無償枠には専用のカウンタと資源の上限があり、そちらが埋まっていました(同時実行数の既定は 3、1 ジョブあたりの CPU 上限が 1)。なお引用したエラー文は有償プール側の判定によるもので、無償側なら末尾に for free が付きます。

リクエスト上限が毎分 30 — ポータルはジョブ一覧を 10 秒ごとに取りに行きます。普通に画面を開いているだけで上限に当たり429 で認証用の nonce まで取れなくなります。

ファイル名が付かない — 成果物のダウンロードで、ノードが Content-Disposition を返しません。ブラウザは名前を決めようがなく file で保存されます(outputs.tar の content-type は application/octet-stream、ログ類は text/plain です)。画面は正しい名前を知っている(RESULTS (outputs.tar) - 2.5 kB と表示できている)のに、保存時に捨てていました。

まとめ

詰まりを分類すると、こうなりました。

種類件数性質
ライブラリの世代差10チェーンと無関係。新しいノードに繋いだ日に一斉に出る
アプリ自身の不具合9 系統版と無関係。うち 2 件は実チェーンでだけ壊れる
環境設定3既定値が検証用途に合っていない

修正は 34 ファイル・+925 / −185 行になりました。半分以上は「値が無いのに無条件に使う」箇所への防御です。

一番の学びは、「動かない」の理由が層をまたいで混ざるということでした。同じ 400 でも、ライブラリが古いのか、アプリが値を確認していないのか、ノードの設定が渋いのかで、直す場所がまったく違います。

そして厄介なのは、成功と失敗の見え方がずれるケースです。

  • ジョブは完走しているのに履歴が空 → 失敗に見える
  • ダウンロードは 45 バイトのエラーテキストが保存される → 成功に見える

どちらも画面だけを見ていると判断を誤ります。今回は落ちてきたファイルの中身を毎回確認することで気づきました。ファイルが取れた=成功、ではありませんでした。

確認できていないこと

  • 有償の経路。今回はすべて無償(無料配布)で通しました。手数料トークンの一覧を扱う部分は空のままで、有償の資産では別途手当てが要るはずですが、試していません
  • 他のポータル実装での再現性。触ったのは 1 つの実装だけです
  • 上流のバージョンを上げた場合。クライアントを現行に上げれば世代差の 9 箇所は消えるはずですが、メジャー 5 世代ぶんの移行なので、別の壊れ方をする可能性があります