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

Pontus-X の公開カタログを curl で覗くでは、データスペースのカタログを外から眺めました。今回は逆側、つまりデータを提供する側のサーバを自分で建てるほうを試します。

Ocean Protocol の Compute-to-Data(C2D、データを渡さずアルゴリズムだけをデータの隣で走らせる仕組み)は、Ocean Node に組み込まれています。ここに実行環境を設定し、誰にジョブを実行させるかを許可リストで制御できることを、レスポンスの差で確かめました。

検証は 2026-08-09、ocean-node 3.2.0(Docker イメージは latest)、Apple Silicon (aarch64) の macOS 上で行っています。

同じリクエストが許可リストの中身だけで止まる位置を変えることを示した図。上段に処理の関門が 4 つ並ぶ。①認証 (address + nonce + signature)、②認可 (validateAccess()、無償ジョブの経路では env.free.access を参照)、③アルゴリズム検証 (イメージ / checksum)、④実行 (コンテナ起動)。下段に 2 つのケース。ケース 1 は access.addresses = [] (全員許可) で、①②を通過して③で停止し HTTP 500 "Cannot find image node@sha256:0000…" を返す。ケース 2 は access.addresses = ["0x…dEaD"] で、②で停止し HTTP 403 "Access denied" を返し、③には到達しない。最下部に「判定は validateAccess() で、アルゴリズムのチェックサム取得より前に走る(無償ジョブは env.free.access を参照)」と記されている。

前提となる構成

Ocean のスタックは、以前は Aquarius(メタデータのカタログ)、Provider(データへの門番)、subgraph(索引)が別々のコンポーネントでしたが、現行の実装ではこの 3 つが Ocean Node 1 つに統合されています。ただし Pontus-X の公開環境は現在も Aquarius と Provider が別に動いているので、世代の違いと考えたほうがよさそうです。

手元では docker compose で Ocean Node と Typesense(索引の保存先)だけを起動しています。次の抜粋は Ocean Node のサービス定義のみで、Typesense の定義は省いています。

services:
  ocean-node:
    image: oceanprotocol/ocean-node:latest
    ports:
      - "8001:8001"
    environment:
      PRIVATE_KEY: '${PRIVATE_KEY}'      # ノードの identity 用。残高もガスも不要
      DB_TYPE: 'typesense'
      DB_URL: 'http://typesense:8108/?apiKey=xyz'
      HTTP_API_PORT: '8001'

この状態で C2D の実行環境を問い合わせると、当然ながら空です。

curl -s http://localhost:8001/api/services/computeEnvironments
[]

DOCKER_COMPUTE_ENVIRONMENTS を設定する

実行環境は環境変数 1 つで定義します。Ocean Node が自分でコンテナを起動するため、ホストの Docker ソケットをマウントする必要があります。

    environment:
      DOCKER_COMPUTE_ENVIRONMENTS: '[{"socketPath":"/var/run/docker.sock", …}]'
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

これは実質的にホストの Docker を操作する権限をコンテナに渡すことになります。手元の検証専用と割り切り、公開ホストでは同じ構成にしないほうが安全です。

公開ドキュメントの例が、そのままでは通りませんでした

ここが今回いちばん時間を使った箇所です。docs/env.md(main ブランチ)に載っている例は、socketPathresourcesmaxJobDuration が同じ階層に並ぶフラットな形です。この形で設定して起動したところ、computeEnvironments[] のままでした。

ログを見ると、エラーではなく警告が 1 行出ているだけでした。

warn: CORE: Skipping C2D Engine 0xff1004b6…: no environments configured

「環境が設定されていない」と言われているので、設定そのものは読み込まれているように見えます。コンテナの中に入って、実際のスキーマを読みました。

docker exec ocean-node sh -c \
  'grep -n -A15 "export const C2DDockerConfigSchema" dist/utils/config/schemas.js'
export const C2DDockerConfigSchema = z.array(z.object({
    socketPath: z.string().optional(),
    protocol: z.string().optional(),
    host: z.string().optional(),
    // …
    scanImages: z.boolean().optional().default(false),
    environments: z.array(C2DEnvironmentConfigSchema).min(1)   // ← これ
}));

3.2.0 では、socketPath と同じ階層に environments 配列が必要でした。なお main ブランチの src/utils/config/schemas.ts にも同じ environments: z.array(...).min(1) があるので、リリース版と main の差ではなく、docs/env.md がコード側と同期していない状態のようです。

同じ節の例には "accessLists": ["0x789"] という書き方も出てきますが、スキーマは後述のとおりチェーン ID をキーにしたオブジェクトの配列を期待するので、こちらも合っていません。

環境ごとの制約も、同じファイルの refine から読み取れます。

.refine((data) => (data.fees !== undefined && Object.keys(data.fees).length > 0) ||
                  (data.free !== undefined && data.free !== null),
  { message: 'Each environment must have either a non-empty "fees" configuration or a "free" configuration' })
.refine((data) => data.storageExpiry >= data.maxJobDuration,
  { message: '"storageExpiry" should be greater than "maxJobDuration"' })
.refine((data) => {
    if (!data.resources) return false;
    return data.resources.some((r) => r.id === 'disk' && r.total);
}, { message: 'There is no "disk" resource configured. This is mandatory' });

まとめると次の 3 点です。

  • fees(有償)か free(無償)のどちらかが必須
  • storageExpirymaxJobDuration 以上
  • resourcestotal を持つ "disk" が必須

これを踏まえた設定がこちらです。

[
  {
    "socketPath": "/var/run/docker.sock",
    "scanImages": false,
    "environments": [
      {
        "id": "local-test",
        "description": "matsuyama local C2D (verification)",
        "storageExpiry": 604800,
        "minJobDuration": 60,
        "maxJobDuration": 600,
        "enableNetwork": false,
        "resources": [
          { "id": "cpu",  "total": 1, "min": 1, "max": 1 },
          { "id": "ram",  "total": 2, "min": 1, "max": 2 },
          { "id": "disk", "total": 5, "min": 1, "max": 5 }
        ],
        "access": { "addresses": [], "accessLists": [] },
        "free": {
          "maxJobDuration": 300,
          "minJobDuration": 10,
          "maxJobs": 1,
          "access": { "addresses": [], "accessLists": [] },
          "resources": [
            { "id": "cpu", "max": 1 }, { "id": "ram", "max": 1 }, { "id": "disk", "max": 1 }
          ]
        }
      }
    ]
  }
]

scanImages は Trivy による脆弱性スキャンの有無です。有効にすると脆弱性データベースの取得が走るので、まずは疎通を見るために false にしています。実運用を考えるなら有効にする項目です。

実行環境が現れました

再起動すると、computeEnvironments が埋まりました。

[
  {
    "id": "0xff1004b6…-0xa2438fc9…",
    "runningJobs": 0,
    "consumerAddress": "0x7EC1B582aBBC7f85c298F792C6fa08EAb9695690",
    "platform": { "architecture": "aarch64", "os": "linux" },
    "access": { "addresses": [], "accessLists": [] },
    "resources": [
      { "id": "cpu",  "type": "cpu",  "total": 1, "max": 1, "min": 1, "inUse": 0 },
      { "id": "ram",  "type": "ram",  "total": 2, "max": 2, "min": 1, "inUse": 0 },
      { "id": "disk", "type": "disk", "total": 5, "max": 5, "min": 1, "inUse": 0 }
    ],
    "storageExpiry": 604800,
    "minJobDuration": 60,
    "maxJobDuration": 600,
    "description": "matsuyama local C2D (verification)"
  }
]

consumerAddress はこのノード自身のアドレスです。形式は、公開されている Pontus-X の Provider に問い合わせたときの応答とそろっています。

curl -s https://provider.dev.pontus-x.eu/api/services/computeEnvironments

こちらは { "32456": [ { "id": "deltadao-ctd", "cpuNumber": 1, "ramGB": "4", … } ] } のようにチェーン ID をキーにした形で返り、フィールドの名前も異なります。世代の違いと思われます。

freeCompute を叩く

無償ジョブは POST /api/services/freeCompute です。まず認証なしで投げると、こう返ります。

"Invalid authentication, you need to provide either a token or an address, signature, message and nonce"

署名の形式は、これもコンテナ内の実装から読めます。

docker exec ocean-node sh -c \
  'sed -n "103,120p" dist/components/core/utils/nonceHandler.js'
const message = String(String(consumer) + String(nonce) + String(command));
const consumerMessage = ethers.solidityPackedKeccak256(
  ['bytes'], [ethers.hexlify(ethers.toUtf8Bytes(message))]);
const messageHashBytes = ethers.toBeArray(consumerMessage);
// verifyMessage(consumerMessage, signature) か
// verifyMessage(messageHashBytes, signature) のどちらかが一致すればよい

つまり consumerAddress + nonce + command を連結した文字列を keccak256 でハッシュし、その結果に対して署名します。commandfreeStartCompute です。nonce は同じアドレスの前回値より大きい必要があるので、エポックミリ秒で十分でした。

使い捨ての鍵で署名するスクリプトです(ethers v5 を使用)。

import { ethers } from 'ethers'

const COMMAND = 'freeStartCompute'
const ENV_ID = '0xff1004b6…-0xa2438fc9…'   // computeEnvironments の id
const wallet = ethers.Wallet.createRandom()
const consumer = wallet.address
const nonce = String(Date.now())

const message = String(consumer) + String(nonce) + String(COMMAND)
const consumerMessage = ethers.utils.solidityKeccak256(
  ['bytes'], [ethers.utils.hexlify(ethers.utils.toUtf8Bytes(message))])
const signature = await wallet.signMessage(ethers.utils.arrayify(consumerMessage))

const res = await fetch('http://localhost:8001/api/services/freeCompute', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    consumerAddress: consumer, address: consumer, nonce, signature,
    environment: ENV_ID,
    datasets: [],
    algorithm: { meta: {
      rawcode: "console.log('hello from c2d')",
      container: { image: 'node', tag: '20-alpine', entrypoint: 'node $ALGO',
                   checksum: 'sha256:0000000000000000000000000000000000000000000000000000000000000000' } } }
  })
})
console.log(res.status, await res.text())

checksum はダミーのままなので、当然ここで弾かれます。

500 "Cannot find image node@sha256:0000000000000000000000000000000000000000000000000000000000000000
     for aarch64. Maybe it does not exist or it's build for other arhitectures."

ジョブの完走はしていませんが、リクエストが認証と認可を通り抜けて、コンテナイメージの検証まで到達したことが分かります。この「どこで止まったか」が、次の確認の物差しになります。

許可リストが効くことを確認する

access.addressesaccess.accessLists が両方とも空だと、すべてのアドレスが許可されます(ドキュメントにも "If both addresses and accessLists are empty, all addresses are allowed" と明記されています)。

そこで、許可リストに自分とは無関係なアドレスだけを入れて、同じリクエストをもう一度投げます。

"access": { "addresses": ["0x000000000000000000000000000000000000dEaD"], "accessLists": [] }
// 環境直下と free 配下の両方の access を、同じ内容に書き換えています

結果です。

403 "Access denied"

同じリクエスト、同じ実行環境で、許可リストの中身だけを変えた比較になります。実行環境の id は設定を書き換えて再起動しても変わらなかったので、投入時の environment はそのまま使い回せました。

access.addressesHTTP応答どこまで進んだか
[]500Cannot find image node@sha256:0000…認可を通過し、イメージ検証まで
["0x…dEaD"]403Access denied認可で停止

判定箇所も確認できます。無償ジョブと有償ジョブで、参照する access が異なります。

docker exec ocean-node sh -c \
  'grep -n "validateAccess(task.consumerAddress" dist/components/core/compute/startCompute.js'
143:  const accessGranted = await validateAccess(task.consumerAddress, env.access, …);
673:  const accessGranted = await validateAccess(task.consumerAddress, env.free.access, …);

今回叩いたのは freeCompute なので、効いているのは後者、つまり free 配下の access です。設定の書き換えでは環境直下と free 配下の両方を同じ内容にしたため、どちらの経路でも同じ結果になります。

判定そのものは共通の関数です。

async function validateAccess(consumerAddress, access, oceanNode) {
    if (!access) return true;
    if (!access.accessLists ||
        (access.accessLists.length === 0 && access.addresses.length === 0)) return true;
    if (access.addresses.includes(consumerAddress)) return true;
    return await checkAddressOnAccessList(consumerAddress, access.accessLists, oceanNode);
}

「両方空なら全許可」がここで実装されています。有償側の呼び出し位置を見ると、アルゴリズムのチェックサムを取りに行く前に判定していることが分かります。

ただし無償経路では、判定の順序が少し違います。datasets に指定されたデータセットの DDO とサービス側の資格情報のチェックが validateAccess より前に走るため、今回のように datasets を空にした場合に限って、認可が最初の関門になります。データセットを指定する場合は、その手前で止まることもありそうです。

NFT による許可リスト

もう一方の accessLists は、アドレスを列挙する代わりにコントラクトを指定する方式です。スキーマはこうなっています。

accessLists: z.array(z.record(z.string(), z.array(z.string()))).nullable().optional()

チェーン ID をキーにしたオブジェクトの配列、つまり [{ "32457": ["0x…"] }] の形です。Ocean Node に内蔵されているベンチマーク環境の定義にも、同じ形が使われていました。

access: {
    addresses: [],
    accessLists: [ { [BASE_CHAIN_ID]: [getAddress('0xcb7Db55Ca9Aa9C3b25F5Bc266da63317fa02086a')] } ]
}

ドキュメントの説明は「Users holding NFTs from these contracts can run compute jobs. Checked across all supported networks.(これらのコントラクトの NFT を保有するユーザーが計算ジョブを実行できる。対応している全ネットワークにわたって確認される)」です。指定するのは ERC-721 のコントラクトで、その保有が実行の条件になります。

判定の実装(src/utils/accessList.ts)は、対象のコントラクトに対して balanceOf を呼び、1 以上なら許可する、という素直なものです。ただし参照先は supportedNetworks[chain] なので、RPCS に登録されていないチェーンを指定すると判定そのものが回りません。

この方式が興味深いのは、発行者を差し替えられる点です。Pontus-X では、参加者クレデンシャルにあたる ERC-721 の保有がテストトークン配布の条件になっていました。同じ形の仕組みを、自分のノードで、自分が選んだ発行元に対して設定できます。

Apple Silicon で引っかかった点と、その回避

ジョブを最後まで走らせようとして、checksum に実在するイメージのダイジェストを指定してみます。このときレジストリからの取得が失敗していたため、手元にダイジェスト付きで存在していた typesense/typesense のイメージを流用しました。結果は同じ 500 でした。ノードのログに理由が出ていました。

warn: CORE: Image typesense/typesense@sha256:f8a9d5… found locally but platform mismatch:
            local=arm64/linux, required=aarch64/linux

「ローカルには見つかったが、プラットフォームが一致しない」とのことです。値を突き合わせると、こうなっていました。

取得元コマンド
実行環境側docker info --format '{{.Architecture}}'aarch64
イメージ側docker inspect --format '{{.Architecture}}'arm64

比較している関数はこちらです。

export function checkManifestPlatform(manifestPlatform, envPlatform) {
    if (!manifestPlatform || !envPlatform) return true;
    if (envPlatform.architecture === 'amd64') envPlatform.architecture = 'x86_64';
    if (manifestPlatform.architecture === 'amd64') manifestPlatform.architecture = 'x86_64';
    if (envPlatform.architecture !== manifestPlatform.architecture ||
        envPlatform.os !== manifestPlatform.os) return false;
    return true;
}

正規化されるのは amd64x86_64 だけで、arm64aarch64 は別の文字列のまま比較されます。x86_64 のホストであれば、環境側が x86_64、イメージ側が amd64x86_64 と正規化されて一致するので、この経路では問題になりません。Apple Silicon 上で C2D のジョブを完走させたい場合は、この点を踏まえる必要がありそうです。

判定関数にパッチを当てると通りました

aarch64arm64 は同じアーキテクチャの別表記なので、amd64 と同じように正規化すれば通るはずです。コンテナから当該ファイルを取り出し、2 行足してマウントし直しました。

docker cp ocean-node:/usr/src/app/dist/components/c2d/compute_engine_docker.js patches/
    if (envPlatform.architecture === 'aarch64') envPlatform.architecture = 'arm64';
    if (manifestPlatform.architecture === 'aarch64') manifestPlatform.architecture = 'arm64';
    volumes:
      - ./patches/compute_engine_docker.js:/usr/src/app/dist/components/c2d/compute_engine_docker.js:ro

まったく同じリクエストの結果が変わりました。

HTTP応答
パッチ前500Cannot find image … for aarch64
パッチ後200{"jobId":"0xff1004b6…-e715c8f5…","status":10,"statusText":"Pulling algorithm image"}

ジョブが受理され、ID が採番され、イメージの取得段階に進みました。ビルド済み成果物へのパッチなので、バージョンを上げるたびに取り直す必要があります。x86_64 のホストでは不要です。

その先はレジストリからの取得で止まりました

続きを追うと、ジョブは次の状態で終わっていました。

status 10 "Pulling algorithm image"
  ↓
status 11 "Pulling algorithm image failed"

ログの理由はこちらです。

error: Unable to pull docker image: typesense/typesense@sha256:f8a9d5…:
       (HTTP code 500) server error - failed to resolve reference
       "docker.io/typesense/typesense@sha256:f8a9d5…": failed to do request:
       Head "https://registry-1.docker.io/v2/…": EOF

ローカルに存在するイメージでも pull を試みる実装のため、レジストリに到達できないと完走しません。手元では Docker Desktop 内蔵プロキシ(http.docker.internal:3128)経由の取得が EOF で失敗する状態でした。docker pull を CLI から直接叩いても同じエラーになるので、Ocean Node 側の問題ではなさそうです。シェルからは registry-1.docker.io に到達できています(401 が返る)。

原因は IPv6 でした

切り分けたところ、失敗するのは Docker Hub からの取得だけでした。

確認内容結果
macOS のシステムプロキシ(scutil --proxy未設定
~/.docker/config.jsonproxies未設定
docker pull public.ecr.aws/docker/library/alpine成功
docker pull node:20-alpine(Docker Hub)EOF で失敗

Docker Desktop の内蔵プロキシのログを見ると、CONNECT のトンネル自体は張れていました。

req 25 HTTP CONNECT registry-1.docker.io:443: proxying to registry-1.docker.io:443
req 25 HTTP CONNECT registry-1.docker.io:443: successful after 303ms with 0 bytes transferred

「成功したが 0 バイト」なので、TLS のやり取りが始まる前に切れています。Docker Desktop の仮想マシンのネットワーク名前空間から、プロトコルを明示して叩いてみました。

docker run --rm --net=host public.ecr.aws/docker/library/alpine sh -c '
  apk add --no-cache curl >/dev/null 2>&1
  curl -4 -sS -o /dev/null -w "IPv4: %{http_code} via %{remote_ip}\n" https://registry-1.docker.io/v2/
  curl -6 -sS -o /dev/null -w "IPv6: %{http_code}\n" https://registry-1.docker.io/v2/'
IPv4: 401 via 100.50.138.97
curl: (35) TLS connect error: error:0A000126:SSL routines::unexpected eof while reading
IPv6: 000

IPv4 なら通り、IPv6 だと TLS が切れます。macOS 側でも同じでした。

IPv4: 401 (1.09s)
IPv6: Connection timed out after 15004 milliseconds

ifconfig で確認したところ、この端末にグローバルな IPv6 アドレスは 1 つも付いていませんでした。使っているネットワークで IPv6 の経路が確立していない状態です。

つながると、次のようになります。

経路挙動理由
macOS のブラウザ・curl通るHappy Eyeballs で IPv4 に落ちる
コンテナ(bridge ネットワーク)通るDocker の内蔵 DNS が IPv4 専用ネットワークでは AAAA を返さない
dockerd の pull失敗AAAA を引いて IPv6 で接続し、フォールバックしない
public.ecr.aws通る引ける address が IPv4

つまり Docker Hub 固有の障害ではなく、AAAA レコードを持つホストに対して、IPv6 の経路がない環境から Docker Desktop が接続を試みている、という話でした。同じ環境なら Docker Hub 以外でも IPv6 を返すレジストリで起きるはずです。

恒久的な対処は IPv6 側の解決(ネットワークを変える、または IPv6 を無効化する)ですが、今回は AWS ECR Public が Docker 公式イメージを public.ecr.aws/docker/library/* にミラーしているので、そちらに差し替えて先に進めました。

docker pull public.ecr.aws/docker/library/node:20-alpine
docker inspect public.ecr.aws/docker/library/node:20-alpine --format '{{index .RepoDigests 0}}'

ジョブが完走しました

パッチとイメージの差し替えを済ませて投入すると、状態が最後まで進みました。

status 10  Pulling algorithm image
status 40  Running algorithm
status 71  Job settling
成果物: image.log(495B) / configuration.log(324B) / algorithm.log(40B) / outputs.tar(1536B)

rawcode にはこれを渡していました。

console.log('hello from c2d');
console.log('sum =', [1,2,3,4].reduce((a,b)=>a+b,0))

成果物の algorithm.log の中身です。

      hello from c2d
      	sum = 10

コンテナの中でアルゴリズムが実行され、標準出力が回収されています。データセットも支払いも伴わない無償ジョブですが、Compute-to-Data の実行部分そのものは動いています。

configuration.log を見ると、渡した生コードが data/transformations/algorithm に書き出され、data/inputs/algoCustomData.json が用意されてから実行されていることが分かります。データセットを指定した場合は、ここに入力が置かれることになります。

なお GET /api/services/computeResult は、署名を付けても 500 "Invalid C2D Environment" が返りました。getC2DByHash がエンジンを見つけられていないようですが、原因は特定できていません。成果物自体はノード内の c2d_storage/<clusterHash>/<jobId>/data/logs/ に置かれているので、確認はそちらで代替しました。

まとめと、確認できなかったこと

今回できたことを整理します。

  • Ocean Node に C2D の実行環境を追加し、computeEnvironments に現れることを確認した
  • 公開ドキュメント(main)の JSON 例は 3.2.0 のスキーマでは通らず、environments の入れ子が必要だった。設定が反映されない場合の出力は警告 1 行で、実行環境は一覧に現れない
  • freeCompute の署名は consumerAddress + nonce + command を keccak256 したものに対して行う
  • 許可リストを変えるだけで、同じリクエストが 500(イメージ検証まで到達)から 403(認可で停止)に変わることを確認した
  • Apple Silicon では arm64aarch64 の表記差でイメージ検証に引っかかる。判定関数に 2 行足したファイルをマウントして回避した
  • Docker Hub からの取得が失敗していたのは、IPv6 の経路がない環境で AAAA レコードを引いていたため。ECR Public のミラーに差し替えて回避した
  • 上記 2 つの回避を経て、アルゴリズムがコンテナ内で実行され、標準出力が成果物として回収されるところまで確認した

確認できていないことも挙げておきます。

  • データセットを伴うジョブ。今回は datasets を空にした無償ジョブだけで、実際のデータを入力に取る経路は試していません
  • 有償ジョブfees を設定して datatoken による支払いを伴う経路は試していません
  • computeResult の 500。成果物はノード内から読めましたが、API 経由での取得は通っていません
  • パッチの妥当性。手元で挙動が変わることは確認しましたが、arm64aarch64 の正規化が上流の設計意図に沿うかどうかまでは確かめていません
  • IPv6 の経路が失われている理由。この端末とネットワークの側の問題までは追っていません
  • accessLists(NFT 方式)の実挙動。今回確認したのは addresses(アドレス列挙)のほうだけです
  • スキーマの読み取りはコンテナ内の dist/ を対象にしており、ソースの型定義と一対一で照合したわけではありません

今回いちばん時間の節約になったのは、公開ドキュメントを読み直すことではなく、コンテナの中の dist/utils/config/schemas.js を直接開いたことでした。バリデーションが zod で書かれているので、必須項目も制約も、そこにそのまま書かれています。ドキュメントとリリース版がずれている場合は、この経路のほうが確実です。