diff --git a/.env.example b/.env.example
index e8dd0da..af44c45 100644
--- a/.env.example
+++ b/.env.example
@@ -21,7 +21,12 @@ OPENAI_MODEL=gpt-4o-mini
# 跑在 TEE 上的可驗證推論。金鑰從 https://pc.0g.ai 取得。
# 沒設也能玩,旁白會退到本地模板並照實標示「未接上 0G Compute」。
OG_COMPUTE_API_KEY=sk-your-0g-router-key
-OG_COMPUTE_MODEL=deepseek-chat-v3-0324
+# 必須挑 verifiability = TeeML 的模型,否則「同一位 agent」的主張不成立。
+# router 的 /v1/models 會標三種:TeeML(模型跑在 TEE 裡)、TeeTLS(只有 TLS 在 TEE
+# 裡,推論在上游廠商)、沒有這個欄位(完全沒 TEE,Claude / GPT 系列屬於這類)。
+# 撰文當下 TeeML 只有:0gm-1.0-35b-a3b、0gm-1.0-35b-a3b-sia、glm-5.3、
+# whisper-large-v3、z-image-turbo。
+OG_COMPUTE_MODEL=0gm-1.0-35b-a3b
# OG_COMPUTE_BASE_URL=https://router-api.0g.ai/v1
# provider 的 attestation(遠端證明)端點。
diff --git a/README.md b/README.md
index 704a1a9..d6de522 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@
| 賽道 | 狀態 | 實測證據 |
|---|---|---|
-| **0G Compute** | ✅ 通 | `deepseek-chat-v3-0324 · TEE 就緒`,戰後旁白由 enclave 產生 |
+| **0G Compute** | ✅ 通 | `0gm-1.0-35b-a3b`(**TeeML** · TDX · dstack)—— 敵方 agent 與戰後旁白都跑在 enclave 裡 |
| **0G Storage** | ✅ 通 | 檔案 root `0x31dbf57395ca6d1b8f401f944453b846ce0c2a0cedddd97c27c4bc8a84d7cb25`
Flow 合約 submit tx `0x9c7377b88930c28412c95dfb452793c500233531264e09fef8537b91358b2218`
儲存費 92200934886 neuron,節點回報 `Single file upload completed` |
| **0G Chain** | ✅ 通 | 區塊 `#54068232`、5 個確認,calldata 讀回後**四項全部相符**(摘要 / 勝負 / 回合數 / 記憶核心) |
@@ -60,14 +60,14 @@
| 用了哪些 0G 技術 | 為什麼要用它 | 在哪一行用到 |
|---|---|---|
| **0G Compute Network**
TEE 可驗證推論 | 戰報要永久存檔,寫它的那個 agent 就不能是黑箱。金鑰在 pc.0g.ai 選 **Private(TEE enclave)** 開的,推論實際跑在 enclave 裡,不是只呼叫一個 OpenAI 相容端點。 | [`functions/api/narrate.js#L55-L134`](https://github.com/ConsssLab/web/blob/main/functions/api/narrate.js#L55-L134) |
-| **0G Compute Network**
Router 設定 | 敵方 agent 與旁白 agent 共用同一份供應商工廠,**兩者預設都指向 0G Compute**。要主張「整場都是同一位 agent」,對打的那個 agent 就必須在 enclave 裡 —— 所以預設不是 OpenAI。 | [`functions/api/og/_shared.js#L22-L23`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L22-L23) · [`functions/api/og/_shared.js#L68-L87`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L68-L87) · [`functions/api/og/_shared.js#L89-L97`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L89-L97) |
+| **0G Compute Network**
Router 設定 | 敵方 agent 與旁白 agent 共用同一份供應商工廠,**兩者預設都指向 0G Compute**。要主張「整場都是同一位 agent」,對打的那個 agent 就必須在 enclave 裡 —— 所以預設不是 OpenAI。 | [`functions/api/og/_shared.js#L22-L23`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L22-L23) · [`functions/api/og/_shared.js#L68-L87`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L68-L87) · [`functions/api/og/_shared.js#L108-L116`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L108-L116) |
| **0G Storage**
真實寫入 | 記憶碎片要「永久保存」就必須真的落地。用官方 SDK 走完整協議:切 256-byte chunk 算 merkle root → 對 Flow 合約送 submit(付儲存費)→ 把 segment 傳給 storage node。**全程用玩家自己的錢包簽,伺服器不持有私鑰。** | [`public/js/storage.js#L72-L116`](https://github.com/ConsssLab/web/blob/main/public/js/storage.js#L72-L116)
節點代理(Node):[`proxy/api/index.js`](https://github.com/ConsssLab/web/blob/main/proxy/api/index.js) —— 節點是裸 IP + 非標準埠,Cloudflare Workers 打不到(error 1003 / 521),這一段必須跑在 Node 上 |
| **0G Storage**
indexer 唯讀查詢 | 「上傳沒報錯」不等於存進去了。拿 root hash 回頭問 indexer,確認 storage node 真的收下並 finalized 才敢標成已存檔。唯讀、不需金鑰,評審可自行查證。 | [`functions/api/og/storage.js#L117-L159`](https://github.com/ConsssLab/web/blob/main/functions/api/og/storage.js#L117-L159) |
-| **0G Storage**
節點活性探測 | 結果畫面的燈號要照實反映網路狀態,不能寫死成綠燈。 | [`functions/api/og/status.js#L34-L57`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L34-L57) |
+| **0G Storage**
節點活性探測 | 結果畫面的燈號要照實反映網路狀態,不能寫死成綠燈。 | [`functions/api/og/status.js#L35-L58`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L35-L58) |
| **0G Chain**
錨定寫入 | 戰報需要一個不可竄改、有時間戳的存在證明。40 bytes 結構化 calldata(魔術字 `CSSW` + 版本 + 戰績 + SHA-256),用合約建立交易送出,chainscan 上一眼認得出來。 | [`functions/api/og/shard.js#L85-L107`](https://github.com/ConsssLab/web/blob/main/functions/api/og/shard.js#L85-L107) · [`public/js/og.js#L235-L260`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L235-L260) |
| **0G Chain**
鏈上回驗 | **這是整個專案的重點**:按了按鈕、錢包沒報錯,不代表資料真的在鏈上。所以再用 `eth_getTransactionByHash` 把交易讀回來、反解 calldata、跟本地碎片逐欄比對,四項全對才打勾。 | [`functions/api/og/verify.js#L19-L47`](https://github.com/ConsssLab/web/blob/main/functions/api/og/verify.js#L19-L47) · [`functions/api/og/verify.js#L49-L104`](https://github.com/ConsssLab/web/blob/main/functions/api/og/verify.js#L49-L104) |
-| **0G Chain**
鏈況與網路切換 | 標題頁即時顯示 Galileo 區塊高度;chainId **從鏈上實際讀回來**校準而不是寫死(0G 換過 chain ID 16601→16602,寫死會讓切鏈整個失敗)。 | [`functions/api/og/status.js#L59-L157`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L59-L157) · [`public/js/og.js#L167-L220`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L167-L220) |
-| — **敵方 AI agent**
(OpenAI,非 0G) | 對戰時每回合都要叫一次,需要低延遲,所以另外走 OpenAI。模型輸出一律當不可信資料重新過濾。 | [`functions/api/agent.js#L188-L291`](https://github.com/ConsssLab/web/blob/main/functions/api/agent.js#L188-L291) · [`public/js/ai.js#L16-L68`](https://github.com/ConsssLab/web/blob/main/public/js/ai.js#L16-L68) |
+| **0G Chain**
鏈況與網路切換 | 標題頁即時顯示 Galileo 區塊高度;chainId **從鏈上實際讀回來**校準而不是寫死(0G 換過 chain ID 16601→16602,寫死會讓切鏈整個失敗)。 | [`functions/api/og/status.js#L60-L177`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L60-L177) · [`public/js/og.js#L167-L220`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L167-L220) |
+| — **敵方 AI agent**
(OpenAI,非 0G) | 對戰時每回合都要叫一次,需要低延遲,所以另外走 OpenAI。模型輸出一律當不可信資料重新過濾。 | [`functions/api/agent.js#L168-L276`](https://github.com/ConsssLab/web/blob/main/functions/api/agent.js#L168-L276) · [`public/js/ai.js#L16-L68`](https://github.com/ConsssLab/web/blob/main/public/js/ai.js#L16-L68) |
### 評審可以自己打的端點
@@ -273,7 +273,7 @@ API 金鑰請選 **Secret**(加密),不要用一般變數。
| 變數 | 賽道 | 必要性 | 說明 |
| --- | --- | --- | --- |
| `OG_COMPUTE_API_KEY` | 賽道一 | **建議設** | 0G Compute Router 金鑰,從 [pc.0g.ai](https://pc.0g.ai) 取得。敵方 agent 與旁白 agent 都吃這把 |
-| `OG_COMPUTE_MODEL` | 賽道一 | 選用 | 預設 `deepseek-chat-v3-0324` |
+| `OG_COMPUTE_MODEL` | 賽道一 | 選用 | 預設 `0gm-1.0-35b-a3b`。**必須挑 `verifiability = TeeML` 的模型** —— `TeeTLS` 只有傳輸層在 enclave 裡,推論在上游廠商那邊跑,撐不起「同一位 agent」的主張 |
| `OG_COMPUTE_ATTESTATION_URL` | 賽道一 | 想驗到 enclave 等級才需要 | provider 的 attestation 端點。沒設的話「同一位 agent」最多只能驗到 `signed`(同一把金鑰),畫面會照實降級顯示 |
| `AI_PROVIDER` | 敵方 agent | 選用 | `0g`(**預設**)或 `openai`。切回 OpenAI 會讓「同一位 agent」的驗證降級 |
| `OPENAI_API_KEY` | 敵方 agent | 只有切回 OpenAI 才要 | **只存在 Function 端**,不會進前端 bundle |
diff --git a/functions/api/agent.js b/functions/api/agent.js
index 8479d64..3013e74 100644
--- a/functions/api/agent.js
+++ b/functions/api/agent.js
@@ -13,9 +13,8 @@
*/
import { sealTurn } from './og/tee.js';
+import { providerConfig } from './og/_shared.js';
-const OPENAI_BASE = 'https://api.openai.com/v1';
-const OG_ROUTER_BASE = 'https://router-api.0g.ai/v1';
const MAX_BODY = 16 * 1024;
const MAX_PLAYS = 4;
@@ -30,35 +29,16 @@ const json = (data, status = 200) =>
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
});
-/**
- * 預設 **0G Compute**,設 AI_PROVIDER=openai 才切回 OpenAI。
+/*
+ * 供應商設定改成從 og/_shared.js 匯入,不要在這裡再寫一份。
*
- * 一開始是反過來的(預設 OpenAI,理由是每回合都要叫、要低延遲)。但那讓「TEE」
- * 變成空話:跟玩家對打的 agent 根本不在 enclave 裡,就無從證明「整場都是同一位」。
- * 要讓那個主張成立,敵方 agent 必須跑在 0G Compute 上並帶回可驗證的簽名。
- * 沒設金鑰時仍會退回本地啟發式,並照實標示「未接上模型」。
+ * 原本這裡有一份幾乎一樣的 providerConfig,包含寫死的模型名。結果是兩個真相來源:
+ * 改了 _shared.js 完全不會影響這支,而實際打 router 的正是這支。
+ * 那個寫死的模型名(deepseek-chat-v3-0324)在 router 上根本不存在,於是每一回合
+ * 都拿到 404 → 靜靜退回本地啟發式 —— 敵方 agent 從來沒有真的跑在 0G 上過,
+ * 而我們一直以為有。
*/
-function providerConfig(env) {
- const choice = String(env.AI_PROVIDER || '0g').toLowerCase();
- if (choice === '0g' || choice === '0g-compute') {
- return {
- id: '0g-compute',
- label: '0G Compute Network Router',
- base: env.OG_COMPUTE_BASE_URL || OG_ROUTER_BASE,
- key: env.OG_COMPUTE_API_KEY,
- model: env.OG_COMPUTE_MODEL || 'deepseek-chat-v3-0324',
- keyName: 'OG_COMPUTE_API_KEY',
- };
- }
- return {
- id: 'openai',
- label: 'OpenAI API',
- base: env.OPENAI_BASE_URL || OPENAI_BASE,
- key: env.OPENAI_API_KEY,
- model: env.OPENAI_MODEL || 'gpt-4o-mini',
- keyName: 'OPENAI_API_KEY',
- };
-}
+
const SYSTEM = `你是回合制策略遊戲《鏈州英雄傳 ConSSS Wars》裡的反派 AI agent「遺忘者」。
你在鏈國 0G 進攻對方的「記憶核心」。你要贏,也要有角色感。
@@ -225,7 +205,12 @@ export async function onRequestPost({ request, env }) {
body: JSON.stringify({
model: cfg.model,
temperature: 0.8,
- max_tokens: 320,
+ max_tokens: 512,
+ // 0G 自家模型預設開著 thinking,實測 max_tokens 會被推理吃光、content 回空字串
+ // (reasoning_tokens 35 / finish_reason "length" / content "")。解析不到 JSON
+ // 就會靜靜退回本地啟發式 —— 正是這個 bug 躲了那麼久的原因。
+ // reasoning_effort: 'none' 實測有效:reasoning_tokens 0,content 直接是乾淨的 JSON。
+ reasoning_effort: 'none',
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: SYSTEM },
diff --git a/functions/api/og/_shared.js b/functions/api/og/_shared.js
index 2323c23..b092c96 100644
--- a/functions/api/og/_shared.js
+++ b/functions/api/og/_shared.js
@@ -86,13 +86,32 @@ export function providerConfig(env) {
};
}
+/**
+ * 預設模型。**必須是 verifiability = TeeML 的那幾個之一。**
+ *
+ * router 的 /v1/models 會標三種狀態,差別直接決定我們的主張成不成立:
+ *
+ * TeeML 模型本身跑在 TEE 裡 ← 只有這個撐得起「同一位 agent」
+ * TeeTLS TEE 只終結 TLS,推論在上游廠商那邊跑(大多數模型是這種)
+ * (沒有) 完全沒有 TEE —— Claude / GPT 系列都屬於這類
+ *
+ * 撰文當下 TeeML 的只有五個:0gm-1.0-35b-a3b、0gm-1.0-35b-a3b-sia、glm-5.3、
+ * whisper-large-v3、z-image-turbo。選 0gm-1.0-35b-a3b 的理由:
+ * · 支援 response_format —— 我們需要 JSON 模式,-sia 那個不支援
+ * · glm-5.3 的 deep thinking 永遠開著且關不掉,逐回合呼叫太慢
+ * · 便宜($0.00000008 / prompt token)
+ *
+ * 換模型前先確認它是 TeeML,否則畫面上的 TEE 主張會變成空話。
+ */
+const OG_COMPUTE_DEFAULT_MODEL = '0gm-1.0-35b-a3b';
+
/** 0G Compute Network Router 的設定。跟敵方 agent 用哪家無關,這個永遠指向 0G。 */
export const ogComputeConfig = (env) => ({
id: '0g-compute',
label: '0G Compute Network Router',
base: env.OG_COMPUTE_BASE_URL || OG_ROUTER_BASE,
key: env.OG_COMPUTE_API_KEY,
- model: env.OG_COMPUTE_MODEL || 'deepseek-chat-v3-0324',
+ model: env.OG_COMPUTE_MODEL || OG_COMPUTE_DEFAULT_MODEL,
keyName: 'OG_COMPUTE_API_KEY',
});
diff --git a/functions/api/og/same-agent.js b/functions/api/og/same-agent.js
index 483cd0d..b94e49d 100644
--- a/functions/api/og/same-agent.js
+++ b/functions/api/og/same-agent.js
@@ -9,8 +9,8 @@
* 分級的定義見 tee.js 的 verifyChain。
*/
-import { json, clampStr, clampInt } from './_shared.js';
-import { verifyChain } from './tee.js';
+import { json, clampStr, clampInt, ogComputeConfig } from './_shared.js';
+import { verifyChain, probeComputeModel } from './tee.js';
const MAX_BODY = 32 * 1024;
const MAX_TURNS = 12;
@@ -24,11 +24,14 @@ const normalizeTurn = (t) => ({
signer: clampStr(t && t.signer, 200) || null,
measurement: clampStr(t && t.measurement, 200) || null,
proofId: clampStr(t && t.proofId, 120) || null,
+ // 實測 0G router 沒有回簽名,但每次回應都帶服務它的 provider 鏈上位址
+ // (標頭 x-provider / body x_0g_trace.provider)。這是目前拿得到的最強身分。
+ providerAddress: clampStr(t && t.providerAddress, 80) || null,
model: clampStr(t && t.model, 60) || null,
provider: clampStr(t && t.provider, 40) || null,
});
-export async function onRequestPost({ request }) {
+export async function onRequestPost({ request, env }) {
let payload;
try {
const raw = await request.text();
@@ -41,7 +44,12 @@ export async function onRequestPost({ request }) {
const chain = Array.isArray(payload && payload.chain)
? payload.chain.slice(0, MAX_TURNS).map(normalizeTurn)
: [];
- const attestation = payload && typeof payload.attestation === 'object' ? payload.attestation : null;
+ // TEE 狀態由伺服器自己去 router 查,不採信前端送來的東西 ——
+ // 這是要證明給玩家看的結論,材料不能由被證明的那一方提供。
+ const probe = await probeComputeModel(ogComputeConfig(env));
+ const attestation = probe.ok
+ ? { modelInTee: probe.modelInTee, verifiability: probe.verifiability, teeType: probe.teeType, teeVerifier: probe.teeVerifier }
+ : { error: probe.error || null };
const verdict = verifyChain(chain, attestation);
return json({
@@ -53,8 +61,14 @@ export async function onRequestPost({ request }) {
responseHash: t.responseHash,
signed: Boolean(t.signature),
signer: t.signer,
+ providerAddress: t.providerAddress,
model: t.model,
- sameAsFirst: !t.signer || !chain[0].signer ? null : t.signer === chain[0].signer,
+ // 「跟第一回合是不是同一個」—— 有簽章公鑰就比公鑰,沒有就比 provider 位址
+ sameAsFirst: (() => {
+ const mine = t.signer || t.providerAddress;
+ const first = chain[0].signer || chain[0].providerAddress;
+ return !mine || !first ? null : mine.toLowerCase() === first.toLowerCase();
+ })(),
})),
});
}
diff --git a/functions/api/og/status.js b/functions/api/og/status.js
index 9556a5e..25711d0 100644
--- a/functions/api/og/status.js
+++ b/functions/api/og/status.js
@@ -10,6 +10,7 @@
*/
import { json, rpc, rpcUrlOf, indexerOf, GALILEO, providerConfig, ogComputeConfig } from './_shared.js';
+import { probeComputeModel } from './tee.js';
/**
* indexer 的活性探測:能拿到節點清單就算通。
@@ -74,7 +75,15 @@ export async function onRequestGet({ env }) {
model: compute.model,
endpoint: compute.base,
configured: Boolean(compute.key),
- tee: Boolean(compute.key),
+ // 這幾格由下面的 probeComputeModel 填。設了金鑰不等於叫得動那個模型,
+ // 更不等於那個模型跑在 TEE 裡 —— 兩件事都要實際查過才敢說。
+ modelAvailable: null,
+ verifiability: null,
+ modelInTee: null,
+ teeAttested: null,
+ teeType: null,
+ tee: false,
+ error: null,
},
// 敵方 AI agent 另外報,因為它照指定走 OpenAI(也可用 AI_PROVIDER=0g 整支切過來)
@@ -125,7 +134,7 @@ export async function onRequestGet({ env }) {
},
};
- const [chainRes, indexerRes] = await Promise.all([
+ const [chainRes, indexerRes, computeRes] = await Promise.all([
Promise.all([
rpc(rpcUrl, 'eth_blockNumber'),
rpc(rpcUrl, 'eth_chainId'),
@@ -135,6 +144,7 @@ export async function onRequestGet({ env }) {
(err) => ({ ok: false, err }),
),
probeIndexer(indexer),
+ probeComputeModel(compute),
]);
if (chainRes.ok) {
@@ -148,6 +158,16 @@ export async function onRequestGet({ env }) {
out.chain.error = String(e && e.message ? e.message : e).slice(0, 160);
}
+ out.compute.modelAvailable = computeRes.ok;
+ out.compute.verifiability = computeRes.verifiability ?? null;
+ out.compute.modelInTee = computeRes.ok ? Boolean(computeRes.modelInTee) : null;
+ out.compute.teeAttested = computeRes.ok ? Boolean(computeRes.teeAttested) : null;
+ out.compute.teeType = computeRes.teeType ?? null;
+ // 只有「模型叫得動」而且「模型本身跑在 TEE 裡」才算 TEE 就緒
+ out.compute.tee = Boolean(computeRes.ok && computeRes.modelInTee);
+ out.compute.error = computeRes.ok ? null : computeRes.error || null;
+ if (computeRes.teeModels) out.compute.teeModels = computeRes.teeModels;
+
out.storage.live = indexerRes.ok;
out.storage.nodeCount = indexerRes.ok ? indexerRes.nodeCount : null;
out.storage.method = indexerRes.ok ? indexerRes.method : null;
diff --git a/functions/api/og/tee.js b/functions/api/og/tee.js
index 904fcb1..e1a4466 100644
--- a/functions/api/og/tee.js
+++ b/functions/api/og/tee.js
@@ -22,7 +22,7 @@
* 也不要因為欄位名猜錯就假裝驗過了 —— 那比沒有這個功能更糟。
*/
-import { sha256Hex } from './_shared.js';
+import { sha256Hex, ogComputeConfig } from './_shared.js';
/** 這些鍵名任一出現(不分大小寫、忽略底線與連字號)就當成該類材料。 */
const KEYS = {
@@ -30,7 +30,10 @@ const KEYS = {
signer: ['signeraddress', 'signingaddress', 'signer', 'publickey', 'pubkey', 'signingkey', 'teepublickey', 'providerpubkey'],
measurement: ['measurement', 'mrenclave', 'mrsigner', 'enclavemeasurement', 'codehash', 'imagehash', 'rtmr'],
quote: ['quote', 'attestation', 'attestationreport', 'rareport', 'teequote', 'evidence'],
- proofId: ['proofid', 'proof', 'requestid', 'inferenceid'],
+ proofId: ['proofid', 'proof', 'requestid', 'inferenceid', 'resid', 'reskey'],
+ // 實測回應裡真正拿得到的身分:服務這一次推論的 provider 鏈上位址。
+ // 標頭是 x-provider,body 是 x_0g_trace.provider,兩邊同值。
+ providerAddress: ['provider', 'provideraddress'],
};
const norm = (k) => String(k).toLowerCase().replace(/[-_\s]/g, '');
@@ -83,6 +86,56 @@ export function extractEvidence(headers, body) {
return found;
}
+/**
+ * 查這個 key 在 router 上看得到哪些模型,並回報設定的那個是什麼狀態。
+ *
+ * 為什麼要查:我們的預設模型名曾經寫成 router 上根本不存在的 deepseek-chat-v3-0324,
+ * 每一回合都拿到 404 → 靜靜退回本地啟發式。整段期間狀態燈是綠的,因為當時只檢查
+ * 「有沒有設金鑰」。有金鑰不代表叫得動那個模型。
+ *
+ * 順便把 verifiability 一起回報,因為它決定 TEE 的主張成不成立:
+ * TeeML 模型本身跑在 TEE 裡 ← 只有這個撐得起「同一位 agent」
+ * TeeTLS TEE 只終結 TLS,推論在上游廠商那邊
+ * (沒有) 完全沒有 TEE
+ */
+export async function probeComputeModel(cfg, timeoutMs = 6000) {
+ if (!cfg.key) return { ok: false, error: `未設定 ${cfg.keyName}` };
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
+ try {
+ const res = await fetch(`${cfg.base.replace(/\/+$/, '')}/models`, {
+ signal: ac.signal,
+ headers: { authorization: `Bearer ${cfg.key}`, accept: 'application/json' },
+ });
+ if (!res.ok) return { ok: false, error: `models ${res.status}` };
+ const data = await res.json();
+ const list = Array.isArray(data && data.data) ? data.data : [];
+ const hit = list.find((m) => m && m.id === cfg.model);
+ if (!hit) {
+ return {
+ ok: false,
+ available: list.length,
+ error: `router 上沒有 ${cfg.model} 這個模型`,
+ teeModels: list.filter((m) => m && m.verifiability === 'TeeML').map((m) => m.id).slice(0, 8),
+ };
+ }
+ return {
+ ok: true,
+ available: list.length,
+ verifiability: hit.verifiability || null,
+ teeAttested: Boolean(hit.tee_attested),
+ teeType: hit.tee_type || null,
+ teeVerifier: hit.tee_verifier || null,
+ // TeeTLS 只保證傳輸層在 enclave 裡,推論本身不是 —— 不能拿來主張「同一位 agent」
+ modelInTee: hit.verifiability === 'TeeML',
+ };
+ } catch (err) {
+ return { ok: false, error: err && err.name === 'AbortError' ? 'models 逾時' : String(err).slice(0, 80) };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
/** 這一回合的證據封包。boardHash 綁住「它看到的盤面」,responseHash 綁住「它說了什麼」。 */
export async function sealTurn({ turn, view, legal, rawResponse, headers, body, model, provider }) {
const evidence = extractEvidence(headers, body);
@@ -96,6 +149,7 @@ export async function sealTurn({ turn, view, legal, rawResponse, headers, body,
provider: provider || null,
signature: evidence.signature ? shorten(evidence.signature) : null,
signer: evidence.signer ? shorten(evidence.signer) : null,
+ providerAddress: evidence.providerAddress ? shorten(evidence.providerAddress) : null,
measurement: evidence.measurement ? shorten(evidence.measurement) : null,
proofId: evidence.proofId ? shorten(evidence.proofId) : null,
};
@@ -123,53 +177,66 @@ export function verifyChain(chain, attestation) {
const turns = Array.isArray(chain) ? chain : [];
if (turns.length === 0) return { level: 'none', ok: false, turns: 0, reason: '沒有任何回合的紀錄' };
- const signers = new Set(turns.map((t) => t.signer).filter(Boolean));
+ const lower = (v) => (v ? String(v).toLowerCase() : null);
+ const signers = new Set(turns.map((t) => lower(t.signer)).filter(Boolean));
+ const addresses = new Set(turns.map((t) => lower(t.providerAddress)).filter(Boolean));
const models = new Set(turns.map((t) => t.model).filter(Boolean));
const providers = new Set(turns.map((t) => t.provider).filter(Boolean));
const signed = turns.filter((t) => t.signature).length;
+ const addressed = turns.filter((t) => t.providerAddress).length;
+ const allSigned = signed === turns.length;
+ const allAddressed = addressed === turns.length;
+ const sameSigner = signers.size === 1;
+ const sameAddress = addresses.size === 1;
const sameModel = models.size <= 1;
const sameProvider = providers.size <= 1;
- const sameSigner = signers.size === 1;
- const allSigned = signed === turns.length;
- const attestedKey = attestation && (attestation.publicKey || attestation.signer) || null;
+ const attestedKey = (attestation && (attestation.publicKey || attestation.signer)) || null;
const keyMatches =
- Boolean(attestedKey) && sameSigner && [...signers][0]
- ? String([...signers][0]).toLowerCase().includes(String(attestedKey).toLowerCase().slice(0, 16))
+ Boolean(attestedKey) && sameSigner
+ ? String([...signers][0]).includes(String(attestedKey).toLowerCase().slice(0, 16))
: false;
+ const modelInTee = Boolean(attestation && attestation.modelInTee);
- let level = 'none';
- if (allSigned && sameSigner && keyMatches && attestation && attestation.measurement) level = 'attested';
+ let level;
+ if (signers.size > 1 || addresses.size > 1) level = 'changed';
+ else if (allSigned && sameSigner && keyMatches && attestation && attestation.measurement) level = 'attested';
else if (allSigned && sameSigner) level = 'signed';
- else if (signed > 0 && signers.size > 1) level = 'changed';
+ else if (allAddressed && sameAddress && modelInTee) level = 'same-provider';
+ else if (allAddressed && sameAddress) level = 'same-provider-untrusted';
else if (sameModel && sameProvider) level = 'consistent';
-
- const noAttest = attestation && attestation.error ? `(${attestation.error})` : '';
- const reason =
- level === 'attested'
- ? '每回合都有簽名、同一把 enclave 公鑰,且對得上 attestation 的 measurement'
- : level === 'signed'
- ? `每回合都有簽名且出自同一把公鑰,但沒取得 attestation${noAttest},無法證明那把金鑰長在 enclave 裡`
- : level === 'changed'
- ? `簽章公鑰中途換過(這 ${turns.length} 個回合出現了 ${signers.size} 把不同的公鑰)—— 這正是「agent 被換掉」的樣子,不能主張是同一位`
- : level === 'consistent'
- ? signed === 0
- ? '供應商沒有回傳任何可驗證的簽名。目前只能說每回合的供應商與模型相同 —— 這是我們伺服器的紀錄,不是密碼學證明'
- : `只有 ${signed}/${turns.length} 個回合帶回簽名,其餘沒有,不足以主張整場都被簽過。供應商與模型本身是一致的,但那只是我們伺服器的紀錄`
- : '每回合的供應商或模型不一致,無法主張是同一位 agent';
+ else level = 'none';
+
+ const teeTag = attestation && attestation.verifiability ? `(${attestation.verifiability}${attestation.teeType ? ' · ' + attestation.teeType : ''})` : '';
+ const reason = {
+ attested: '每回合都有簽名、同一把 enclave 公鑰,且對得上 attestation 的 measurement',
+ signed: `每回合都有簽名且出自同一把公鑰,但沒取得 attestation${attestation && attestation.error ? `(${attestation.error})` : ''},無法證明那把金鑰長在 enclave 裡`,
+ 'same-provider': `每一回合都由同一個 provider(${[...addresses][0]})服務,而該模型在 router 上登記為在 TEE 內執行${teeTag}。0G 目前不隨回應附簽名,所以這一級靠的是 router 的回報,不是我們自己驗過的密碼學證明`,
+ 'same-provider-untrusted': `每一回合都由同一個 provider(${[...addresses][0]})服務,但該模型沒有登記為在 TEE 內執行 —— 換一個 verifiability = TeeML 的模型才撐得起 TEE 的主張`,
+ changed: signers.size > 1
+ ? `簽章公鑰中途換過(出現 ${signers.size} 把)—— 這正是「agent 被換掉」的樣子`
+ : `provider 位址中途換過(出現 ${addresses.size} 個)—— 不能主張整場是同一位 agent`,
+ consistent: `拿不到 provider 位址也拿不到簽名(${signed}/${turns.length} 回合有簽名)。目前只能說每回合的供應商與模型相同 —— 這是我們伺服器的紀錄,不是密碼學證明`,
+ none: '每回合的供應商或模型不一致,無法主張是同一位 agent',
+ }[level];
return {
level,
+ // ok 只留給真正驗過密碼學證明的那一級。same-provider 是有意義的證據,但不是證明。
ok: level === 'attested',
turns: turns.length,
signedTurns: signed,
+ addressedTurns: addressed,
sameSigner,
+ sameAddress,
sameModel,
sameProvider,
signer: sameSigner ? [...signers][0] : null,
+ providerAddress: sameAddress ? [...addresses][0] : null,
models: [...models],
providers: [...providers],
+ verifiability: (attestation && attestation.verifiability) || null,
measurement: (attestation && attestation.measurement) || null,
reason,
};
diff --git a/public/css/style.css b/public/css/style.css
index 9057e2b..0a1528e 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1269,6 +1269,13 @@ body {
.tee-badge[data-level='attested'] { background: var(--blue); color: #fff; border-color: var(--blue); }
.tee-badge[data-level='signed'] { background: #F2C36B; color: var(--ink); }
+/*
+ * same-provider 是實測下最常見的一級:每回合都同一個 provider,模型登記為 TeeML。
+ * 它是有意義的證據,但不是我們自己驗過的密碼學證明 —— 所以給琥珀色,不給藍色。
+ * 藍色只留給 attested。
+ */
+.tee-badge[data-level='same-provider'] { background: #F2C36B; color: var(--ink); }
+.tee-badge[data-level='same-provider-untrusted'] { background: var(--paper-edge); color: var(--muted); }
.tee-badge[data-level='consistent'] { background: var(--paper-edge); color: var(--muted); }
.tee-badge[data-level='changed'] { background: var(--terracotta); color: #fff; border-color: var(--terracotta); }
.tee-badge[data-level='none'] { background: var(--terracotta); color: #fff; border-color: var(--terracotta); }
diff --git a/public/js/main.js b/public/js/main.js
index dab775a..d634215 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -890,10 +890,19 @@ async function pollStorage(root, note, attempts = 6) {
* 結果分四級,刻意不做成通過/不通過:能證明到哪一層完全取決於供應商回了什麼。
* 把「只是紀錄一致」畫成綠燈,比沒有這個功能更糟。
*/
+/**
+ * 等級的文案刻意分得很細,因為能證明到哪一層完全取決於供應商給了什麼材料。
+ *
+ * 實測 0G router **不隨回應附簽名**,但每次回應都帶服務它的 provider 鏈上位址
+ * (標頭 x-provider)。所以現實中最常落在 same-provider —— 它是有意義的證據
+ * (provider 中途被換掉會被抓到),但不是我們自己驗過的密碼學證明,文案必須講清楚。
+ */
const TEE_BADGE = {
attested: '已驗證 · enclave 等級',
signed: '部分驗證 · 缺 attestation',
- changed: '⚠ 公鑰中途換過',
+ 'same-provider': '同一個 provider · TEE 模型',
+ 'same-provider-untrusted': '同一個 provider · 但非 TEE 模型',
+ changed: '⚠ 中途換過 agent',
consistent: '未驗證 · 僅紀錄一致',
none: '無法驗證',
};
@@ -906,15 +915,12 @@ async function verifyTee() {
btn.textContent = '驗證中…';
try {
- // attestation 拿不到不算失敗 —— 它只是決定最高能驗到哪一級
- const attestation = await fetch('/api/og/attest', { headers: { accept: 'application/json' } })
- .then((r) => (r.ok ? r.json() : null))
- .catch(() => null);
-
+ // TEE 狀態由伺服器自己去 router 查 —— 要證明給玩家看的結論,
+ // 材料不能由被證明的那一方(前端)提供。
const res = await fetch('/api/og/same-agent', {
method: 'POST',
headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ chain: game.teeChain, attestation }),
+ body: JSON.stringify({ chain: game.teeChain }),
});
if (!res.ok) throw new Error(`same-agent ${res.status}`);
const v = await res.json();
@@ -925,13 +931,15 @@ async function verifyTee() {
badge.dataset.level = v.level;
reason.textContent = v.reason;
- // 逐回合亮燈:同一把公鑰=藍、換過=橘、沒簽名=空心
+ // 逐回合亮燈:跟第一回合同一個身分=藍、換過=橘、完全沒身分=空心。
+ // 身分優先看簽章公鑰,沒有就看 provider 位址。
const list = $('tee-turns');
list.textContent = '';
for (const t of v.perTurn || []) {
const li = document.createElement('li');
li.className = 'tee-turn';
- li.dataset.state = !t.signed ? 'unsigned' : t.sameAsFirst === false ? 'changed' : 'ok';
+ const identified = t.signed || Boolean(t.providerAddress);
+ li.dataset.state = !identified ? 'unsigned' : t.sameAsFirst === false ? 'changed' : 'ok';
const dot = document.createElement('span');
dot.className = 'tee-turn-dot';
const label = document.createElement('span');
@@ -950,10 +958,11 @@ async function verifyTee() {
const meta = $('tee-meta');
meta.textContent = '';
const rows = [
- ['回合數', `${v.signedTurns} / ${v.turns} 有簽名`],
+ ['回合數', `${v.turns} 回合,其中 ${v.addressedTurns ?? 0} 回合帶有 provider 位址、${v.signedTurns} 回合帶有簽名`],
['模型', (v.models || []).join(' / ') || '—'],
- ['供應商', (v.providers || []).join(' / ') || '—'],
- ['簽章公鑰', v.signer || '未取得'],
+ ['TEE 等級', v.verifiability ? `${v.verifiability}(TeeML 才是模型跑在 enclave 裡)` : '未取得'],
+ ['provider 位址', v.providerAddress || '未取得或中途換過'],
+ ['簽章公鑰', v.signer || '0G 目前不隨回應附簽名'],
['enclave measurement', v.measurement || '未取得'],
];
for (const [k, val] of rows) {