Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@ OG_COMPUTE_MODEL=deepseek-chat-v3-0324
# 不需要在這裡設任何東西,伺服器也不碰私鑰。
OG_STORAGE_INDEXER=https://indexer-storage-testnet-turbo.0g.ai

# 上傳要經過的節點代理。
#
# 0G 的 storage node 是裸 IP + 非標準埠(http://34.19.125.196:5678),
# Cloudflare Workers 兩樣都打不到(error 1003 與 521),所以轉發那一段
# 必須放在 Node 上 —— 見 proxy/README.md,部署完把網址填在這裡。
# 不設的話前端走同源的 /api/og/zg,在 Cloudflare 上會失敗。
OG_ZG_PROXY_BASE=

# 選用:想讓玩家完全不用付儲存費的話,自己架一個 0g-storage-client gateway,
# 設了這個端點就改由 Function 轉發上傳(那台 gateway 才需要持有私鑰)。
OG_STORAGE_UPLOAD_URL=
OG_STORAGE_TOKEN=

# ══ 賽道三:0G Chain(Galileo 測試網 16601)══════════════════════
# ══ 賽道三:0G Chain(Galileo 測試網 16602)══════════════════════
# 不設就用公開節點。錨定交易由玩家自己的錢包送出,伺服器不保管任何私鑰。
OG_RPC_URL=https://evmrpc-testnet.0g.ai
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<img src="public/images/logo.svg" alt="ConSSS Wars" width="150">

# 鏈之英雄傳 ConSSS Wars
# 鏈州英雄傳 ConSSS Wars

### 第 0 章 · 無重之憶 — Weightless Memory

Expand Down Expand Up @@ -47,11 +47,21 @@
摘要 ──合約建立交易──▶ 錨定到 0G Chain ──讀回來逐欄比對──▶ ✓
```

**三條賽道都已在 Galileo 測試網跑通**,下面是其中一場的實際紀錄(可自行到瀏覽器重現):

| 賽道 | 狀態 | 實測證據 |
|---|---|---|
| **0G Compute** | ✅ 通 | `deepseek-chat-v3-0324 · TEE 就緒`,戰後旁白由 enclave 產生 |
| **0G Storage** | ✅ 通 | 檔案 root `0x31dbf57395ca6d1b8f401f944453b846ce0c2a0cedddd97c27c4bc8a84d7cb25`<br>Flow 合約 submit tx `0x9c7377b88930c28412c95dfb452793c500233531264e09fef8537b91358b2218`<br>儲存費 92200934886 neuron,節點回報 `Single file upload completed` |
| **0G Chain** | ✅ 通 | 區塊 `#54068232`、5 個確認,calldata 讀回後**四項全部相符**(摘要 / 勝負 / 回合數 / 記憶核心) |

> 賽道二繞了很久才通。0G 的 storage node 是**裸 IP + 明文 http + 5678 埠**,瀏覽器擋 mixed content、Cloudflare Workers 又擋裸 IP(`error 1003`)與非標準埠(`error 521`),所以節點轉發那一小段必須跑在 Node 上([`proxy/`](proxy/))。細節寫在 [proxy/README.md](proxy/README.md)。

| 用了哪些 0G 技術 | 為什麼要用它 | 在哪一行用到 |
|---|---|---|
| **0G Compute Network**<br>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**<br>Router 設定 | 敵方 agent 與旁白 agent 共用同一份供應商工廠,把 `AI_PROVIDER` 改成 `0g` 就能整支切過去,不用改程式碼。 | [`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#L82-L90`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L82-L90) |
| **0G Storage**<br>真實寫入 | 記憶碎片要「永久保存」就必須真的落地。用官方 SDK 走完整協議:切 256-byte chunk 算 merkle root → 對 Flow 合約送 submit(付儲存費)→ 把 segment 傳給 storage node。**全程用玩家自己的錢包簽,伺服器不持有私鑰。** | [`public/js/storage.js#L53-L85`](https://github.com/ConsssLab/web/blob/main/public/js/storage.js#L53-L85) |
| **0G Storage**<br>真實寫入 | 記憶碎片要「永久保存」就必須真的落地。用官方 SDK 走完整協議:切 256-byte chunk 算 merkle root → 對 Flow 合約送 submit(付儲存費)→ 把 segment 傳給 storage node。**全程用玩家自己的錢包簽,伺服器不持有私鑰。** | [`public/js/storage.js#L65-L108`](https://github.com/ConsssLab/web/blob/main/public/js/storage.js#L65-L108)<br>節點代理(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**<br>indexer 唯讀查詢 | 「上傳沒報錯」不等於存進去了。拿 root hash 回頭問 indexer,確認 storage node 真的收下並 finalized 才敢標成已存檔。唯讀、不需金鑰,評審可自行查證。 | [`functions/api/og/storage.js#L76-L114`](https://github.com/ConsssLab/web/blob/main/functions/api/og/storage.js#L76-L114) |
| **0G Storage**<br>節點活性探測 | 結果畫面的燈號要照實反映網路狀態,不能寫死成綠燈。 | [`functions/api/og/status.js#L34-L57`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L34-L57) |
| **0G Chain**<br>錨定寫入 | 戰報需要一個不可竄改、有時間戳的存在證明。40 bytes 結構化 calldata(魔術字 `CSSW` + 版本 + 戰績 + SHA-256),用合約建立交易送出,chainscan 上一眼認得出來。 | [`functions/api/og/shard.js#L62-L84`](https://github.com/ConsssLab/web/blob/main/functions/api/og/shard.js#L62-L84) · [`public/js/og.js#L174-L199`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L174-L199) |
Expand Down
2 changes: 1 addition & 1 deletion functions/api/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function providerConfig(env) {
};
}

const SYSTEM = `你是回合制策略遊戲《鏈之英雄傳 ConSSS Wars》裡的反派 AI agent「遺忘者」。
const SYSTEM = `你是回合制策略遊戲《鏈州英雄傳 ConSSS Wars》裡的反派 AI agent「遺忘者」。
你在鏈國 0G 進攻對方的「記憶核心」。你要贏,也要有角色感。

戰場規則:
Expand Down
2 changes: 1 addition & 1 deletion functions/api/narrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const MAX_BODY = 8 * 1024;
const TIMEOUT_MS = 9000;
const MAX_NARRATION = 120;

const SYSTEM = `你是《鏈之英雄傳 ConSSS Wars》裡的「記憶編纂者」。
const SYSTEM = `你是《鏈州英雄傳 ConSSS Wars》裡的「記憶編纂者」。
玩家剛在鏈國 0G 打完一場記憶迴廊的攻防,你要把這場戰鬥寫成一段要永久存進 0G Storage 的檔案敘述。

規則:
Expand Down
7 changes: 7 additions & 0 deletions functions/api/og/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export async function onRequestGet({ env }) {
role: '記憶碎片永久存檔(前端用玩家錢包上傳)',
indexer,
network: 'turbo',
// 上傳走哪一支代理。
//
// 預設是同源的 Pages Function,但那條路在 Cloudflare 上被平台擋死:
// Worker 不能打裸 IP(error 1003),也打不到 5678 這種非標準埠(521),
// 而 0G 的 storage node 兩樣都佔。設 OG_ZG_PROXY_BASE 指向 proxy/
// 那支 Node 服務,上傳才會真的通。前端照這個值決定要打哪裡。
zgProxy: env.OG_ZG_PROXY_BASE ? String(env.OG_ZG_PROXY_BASE).replace(/\/+$/, '') : '/api/og/zg',
// 預設由前端用玩家錢包上傳(public/js/storage.js);
// 設了 OG_STORAGE_UPLOAD_URL 就改由 Function 轉發給自架 gateway。
uploadMode: env.OG_STORAGE_UPLOAD_URL ? 'server-gateway' : 'client-wallet',
Expand Down
166 changes: 133 additions & 33 deletions functions/api/og/zg/[[path]].js
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,87 @@
* 1. indexer:問「這個檔案該傳給哪些 storage node」(indexer_getShardedNodes)
* 2. 那份清單裡的每一台 storage node:真正把 segment 傳上去(zgs_uploadSegment)
*
* 第 1 步從瀏覽器打得通,第 2 步打不通:那些 storage node 是各自獨立的主機,
* 沒有為瀏覽器開 CORS,所以 axios 只會回一句沒有內容的 "Network Error"。
* 第 1 步從瀏覽器打得通,第 2 步打不通。實測拿到的節點長這樣:
*
* 解法是把兩步都繞過這支 Function,讓瀏覽器全程只跟自己的網域講話:
* http://34.19.125.196:5678
*
* 裸 IP、明文 http、非標準埠 —— 對一個 https 的頁面來說這是 mixed content,
* 瀏覽器連送都不會送就直接擋掉(Chrome console 明講 "This request has been
* blocked; the content must be served over HTTPS")。就算改成 https,那些節點
* 也沒為瀏覽器開 CORS。兩個問題都只有一個解法:不要讓瀏覽器直接碰它們。
*
* 前端 new Indexer('/api/og/zg/indexer')
* │
* ├─ 這支轉發到真的 indexer,
* │ 並把回應裡每個節點的 url 改寫成 /api/og/zg/node/<編碼後的原始網址>
* │ 並把回應裡每個節點的 url 改寫成 /api/og/zg/node/<簽章>/<編碼後的原始網址>
* │
* └─ 節點請求再由這支轉發到那台真的 storage node
*
* Flow 合約那步不經過這裡 —— 它走 MetaMask 簽名,本來就沒有 CORS 問題。
* 整條鏈路對瀏覽器來說都是同源的 https,mixed content 與 CORS 一起消失。
* Flow 合約那步不經過這裡 —— 它走 MetaMask 簽名,本來就沒有這兩個問題。
*
* 安全性:只准轉發到 0g.ai 底下的 https 主機,否則就成了任何人都能借用的開放代理。
* 安全性:節點網址是裸 IP,沒有網域可以白名單,所以改用簽章 ——
* 只有這支代理自己從 indexer 回應裡吐出來的網址才轉發得動(HMAC 綁住),
* 外人塞一個任意網址進來會被擋,不會變成人人可用的開放代理。
* 另外再擋掉內網位址,避免有人拿它去打 Cloudflare 內部或 metadata 端點。
*/

import { json, indexerOf } from '../_shared.js';

/** 只有這個網域(與其子網域)能被轉發。 */
const ALLOWED_SUFFIX = '.0g.ai';
const ALLOWED_EXACT = '0g.ai';
/** indexer 本身仍然只認 0g.ai 的 https(它是設定值,不是外部資料)。 */
const INDEXER_SUFFIX = '.0g.ai';
const INDEXER_EXACT = '0g.ai';

const MAX_BODY = 6 * 1024 * 1024; // 一個 segment 是 256KB,留足餘裕
const TIMEOUT_MS = 30000;

const b64urlEncode = (s) =>
btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const enc = new TextEncoder();

const b64urlEncode = (s) => btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

const b64urlDecode = (s) => {
const pad = s.replace(/-/g, '+').replace(/_/g, '/');
return atob(pad + '='.repeat((4 - (pad.length % 4)) % 4));
};

/** 這個網址可以轉發嗎?只認 https 的 0g.ai。 */
function allowed(target) {
/**
* 簽章金鑰。設了 OG_PROXY_SECRET 就用它,沒設就退回內建常數 ——
* 這個簽章擋的是「有人拿我們的網域當跳板」,不是機密資料,
* 沒設也不該讓整個上傳功能掛掉。要更嚴就在 Pages 設一個 secret。
*/
const secretOf = (env) => (env && env.OG_PROXY_SECRET) || 'conssswars/og-zg-proxy/v1';

const keyCache = new Map();
function signingKey(secret) {
if (!keyCache.has(secret)) {
keyCache.set(
secret,
crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
'sign',
]),
);
}
return keyCache.get(secret);
}

/** 取 HMAC 前 16 bytes 轉十六進位:32 個字,夠短也夠難猜。 */
async function sign(url, env) {
const mac = await crypto.subtle.sign('HMAC', await signingKey(secretOf(env)), enc.encode(url));
return Array.from(new Uint8Array(mac).slice(0, 16))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

/** 定時比對,不要因為提早 return 而洩漏正確簽章的前綴。 */
function sameSig(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}

/** indexer 設定值的檢查:https 而且在 0g.ai 底下。 */
function allowedIndexer(target) {
let u;
try {
u = new URL(target);
Expand All @@ -51,28 +96,79 @@ function allowed(target) {
}
if (u.protocol !== 'https:') return null;
const h = u.hostname.toLowerCase();
if (h !== ALLOWED_EXACT && !h.endsWith(ALLOWED_SUFFIX)) return null;
if (h !== INDEXER_EXACT && !h.endsWith(INDEXER_SUFFIX)) return null;
return u;
}

/**
* 把 JSON 裡所有指向外部主機的 url 欄位改寫成走這支代理。
* 內網位址一律拒絕。
*
* 節點網址是 indexer 給的,正常情況都是公網 IP;但簽章金鑰萬一外流,
* 這道防線可以讓這支代理仍然打不到任何內部服務(含雲端 metadata 端點)。
*/
Comment on lines +104 to +108
const PRIVATE_V4 =
/^(0|10|127)\.|^169\.254\.|^172\.(1[6-9]|2\d|3[01])\.|^192\.168\.|^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./;

function publicTarget(raw) {
let u;
try {
u = new URL(raw);
} catch {
return null;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;

const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) {
return null;
}
if (PRIVATE_V4.test(h)) return null;
if (h === '::1' || h === '::' || /^f[cd]/.test(h) || h.startsWith('fe80:')) return null;
return u;
}

/**
* Cloudflare 不讓 Worker 對裸 IP 發出站請求 —— 會直接回 error code 1003
* (Direct IP Access Not Allowed),連線根本沒發出去。而 0G 的 storage node
* 清一色是裸 IP(實測 http://34.19.125.196:5678)。
*
* sslip.io 是一個公開的 DNS 服務:a.b.c.d.sslip.io 永遠解析回 a.b.c.d。
* 換上它之後 Cloudflare 有 hostname 可以打,連到的還是同一台機器同一個埠。
*
* 只對裸 IPv4 動手。indexer 之後若改成回傳網域名稱,這裡就自動不生效。
*/
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
const IP_DNS_SUFFIX = '.sslip.io';

function dnsResolvable(url) {
if (IPV4.test(url.hostname)) url.hostname = url.hostname + IP_DNS_SUFFIX;
return url;
}

/**
* 把 JSON 裡所有指向 storage node 的 url 欄位改寫成走這支代理。
*
* indexer 回傳的節點清單形狀在不同版本之間變過(有時是陣列,有時是
* { trusted: [...] } 這種分片物件),所以不去假設結構,直接走訪整棵樹,
* 看到叫 url 而且是 http(s) 開頭的字串就換掉。轉發不到的主機(非 0g.ai)
* 原樣留著,讓它自己去失敗,不要靜悄悄地吞掉。
* 看到叫 url 而且是 http(s) 開頭的字串就換掉。打不到的(內網位址)原樣留著,
* 讓它自己去失敗,不要靜悄悄地吞掉。
*
* 這裡是非同步的,因為每個網址都要簽一次章。
*/
function rewriteUrls(node, origin) {
if (Array.isArray(node)) return node.map((n) => rewriteUrls(n, origin));
async function rewriteUrls(node, origin, env) {
if (Array.isArray(node)) return Promise.all(node.map((n) => rewriteUrls(n, origin, env)));
if (!node || typeof node !== 'object') return node;

const out = {};
for (const [k, v] of Object.entries(node)) {
if (k === 'url' && typeof v === 'string' && /^https?:\/\//i.test(v)) {
out[k] = allowed(v) ? `${origin}/api/og/zg/node/${b64urlEncode(v)}` : v;
if (publicTarget(v)) {
out[k] = `${origin}/api/og/zg/node/${await sign(v, env)}/${b64urlEncode(v)}`;
} else {
out[k] = v;
}
} else {
out[k] = rewriteUrls(v, origin);
out[k] = await rewriteUrls(v, origin, env);
}
}
return out;
Expand Down Expand Up @@ -115,9 +211,10 @@ async function forward(target, request, { rewrite = null } = {}) {
headers: { 'content-type': res.headers.get('content-type') || 'text/plain' },
});
}
return json(rewrite(parsed), res.status);
return json(await rewrite(parsed), res.status);
} catch (err) {
const msg = err && err.name === 'AbortError' ? '轉發逾時' : String(err && err.message ? err.message : err);
const msg =
err && err.name === 'AbortError' ? '轉發逾時' : String(err && err.message ? err.message : err);
return json({ error: `代理轉發失敗:${msg}`.slice(0, 200) }, 502);
} finally {
clearTimeout(timer);
Expand All @@ -131,31 +228,34 @@ export async function onRequest({ request, params, env }) {
// ── /api/og/zg/indexer ──────────────────────────────
// 轉發到真的 indexer,並把回應裡的節點網址改寫成走這支代理。
if (segments[0] === 'indexer' && segments.length === 1) {
const target = allowed(indexerOf(env));
const target = allowedIndexer(indexerOf(env));
if (!target) return json({ error: 'indexer 設定不是合法的 0g.ai https 網址' }, 500);
return forward(target, request, { rewrite: (data) => rewriteUrls(data, origin) });
return forward(target, request, { rewrite: (data) => rewriteUrls(data, origin, env) });
}

// ── /api/og/zg/node/<編碼網址>/<其餘路徑> ─────────────
// 轉發到那台真的 storage node。
if (segments[0] === 'node' && segments.length >= 2) {
// ── /api/og/zg/node/<簽章>/<編碼網址>/<其餘路徑> ───────
// 轉發到那台真的 storage node。簽章不對就不轉。
if (segments[0] === 'node' && segments.length >= 3) {
let decoded;
try {
decoded = b64urlDecode(segments[1]);
decoded = b64urlDecode(segments[2]);
} catch {
return json({ error: '節點網址編碼不正確' }, 400);
}
const base = allowed(decoded);
if (!base) return json({ error: '只允許轉發到 0g.ai 的 https 主機' }, 403);
if (!sameSig(segments[1], await sign(decoded, env))) {
return json({ error: '節點網址簽章不符:這支代理只轉發自己發出的節點位址' }, 403);
}
const base = publicTarget(decoded);
if (!base) return json({ error: '不允許轉發到這個位址' }, 403);

// 保留節點網址本身的路徑,再接上代理路徑剩下的部分
const rest = segments.slice(2).map(encodeURIComponent).join('/');
const rest = segments.slice(3).map(encodeURIComponent).join('/');
const target = new URL(
[base.pathname.replace(/\/+$/, ''), rest].filter(Boolean).join('/') || '/',
base.origin,
);
target.search = new URL(request.url).search;
return forward(target, request);
return forward(dnsResolvable(target), request);
}

return json({ error: 'unknown proxy route' }, 404);
Expand Down
Loading