From 9a40798bee1207e8620fd6699580576621fe9f87 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:54:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E3=80=8C=E5=9C=A8=200G=20Storage=20?= =?UTF-8?q?=E4=B8=8A=E6=9F=A5=E7=9C=8B=E3=80=8D=E6=94=B9=E6=8C=87=20submis?= =?UTF-8?q?sion=20=E9=A0=81=EF=BC=8C=E4=B8=A6=E7=AD=89=E6=9F=A5=E5=88=B0?= =?UTF-8?q?=E5=BA=8F=E8=99=9F=E6=89=8D=E8=A7=A3=E9=8E=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按下去 404: https://storagescan-galileo.0g.ai/file/0x2aba519d5e95ec4ee85fc6d549e29e4e5f7367fe6abf70b67432e81b9718da4b storagescan 沒有 /file/ 這條路徑,那是我當初憑印象組的。它的檔案頁是用 提交序號定位的 —— /submission/(例如 /submission/2492640)。 序號要跟 indexer 查:/file/info/ 的回應裡有 tx.seq。不同版本也出現過 txSeq / seq 這幾種寫法,所以 seqOf() 都認一下,一個都拿不到就回 null。 前端不再上傳完就把連結點亮。segment 傳完不等於索引好,節點要先把 log entry 同步進來 indexer 才答得出序號,實測中間有幾秒到十幾秒。所以改成輪詢(最多 6 次、 每次隔 3 秒),查到序號才解鎖按鈕;輪完還是沒有就維持不可按,並說明檔案已經上傳、 只是還沒被索引到 —— 不要給玩家一個會 404 的連結。 seqOf 的邊界(12 個案例全過):Number(null) 是 0,不擋的話「查不到」會變成看起來 合法的 /submission/0。所以先擋掉 null / undefined / 空字串,再拒絕負數與小數。 伺服器端 gateway 那條路徑(shard.js)同樣改掉,沒有序號就回 scanUrl: null。 未驗證的部分:這個容器的 egress 擋掉 *.0g.ai,/submission/ 的格式來自 搜尋到的真實網址,indexer 回應裡 tx.seq 的欄位名沒有實際打過。拿不到序號時 按鈕會留成不可按,所以最壞情況是連結沒出現,不會再出現 404。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3 --- functions/api/og/shard.js | 4 ++- functions/api/og/storage.js | 27 ++++++++++++++- public/js/main.js | 65 ++++++++++++++++++++++++++++--------- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/functions/api/og/shard.js b/functions/api/og/shard.js index bb55a64..12ae97c 100644 --- a/functions/api/og/shard.js +++ b/functions/api/og/shard.js @@ -151,7 +151,9 @@ async function uploadToStorage(env, shard, bytes) { indexer, root: root || null, txSeq: txSeq === null ? null : clampInt(txSeq, 0, Number.MAX_SAFE_INTEGER), - scanUrl: root ? `${GALILEO.storageScan}/file/${root}` : null, + // storagescan 是用提交序號定位的(/submission/),沒有 /file/。 + // gateway 有回序號才給得出連結,否則留 null,不要給一個 404 的網址。 + scanUrl: txSeq === null ? null : `${GALILEO.storageScan}/submission/${txSeq}`, response: body, }; } catch (err) { diff --git a/functions/api/og/storage.js b/functions/api/og/storage.js index 18f2168..aedf28e 100644 --- a/functions/api/og/storage.js +++ b/functions/api/og/storage.js @@ -73,6 +73,29 @@ async function indexerNodes(indexer) { throw new Error(errors.join(' | ').slice(0, 160)); } +/** + * storagescan 的檔案頁是用「提交序號」定位的,不是 root hash: + * + * https://storagescan-galileo.0g.ai/submission/ + * + * 這個站沒有 /file/ 這種路徑(我們原本這樣組,點下去是 404)。 + * 序號來自 indexer 回的檔案資訊 —— 0g-storage-node 把它放在 tx.seq, + * 不同版本也出現過 txSeq / seq 這幾種寫法,所以都認一下。 + * 拿不到就回 null,讓前端照實把按鈕留成不可按,而不是給一個會 404 的連結。 + */ +function seqOf(body) { + const cands = [body && body.tx && body.tx.seq, body && body.tx && body.tx.txSeq, body && body.txSeq, body && body.seq]; + for (const v of cands) { + // 先擋掉 null / undefined / 空字串 —— Number(null) 是 0,會讓「查不到」 + // 變成一個看起來合法的 /submission/0 連結。 + if (typeof v !== 'number' && typeof v !== 'string') continue; + if (v === '') continue; + const n = Number(v); + if (Number.isInteger(n) && n >= 0) return n; + } + return null; +} + export async function onRequestGet({ request, env }) { const url = new URL(request.url); const root = String(url.searchParams.get('root') || '').trim(); @@ -96,14 +119,16 @@ export async function onRequestGet({ request, env }) { try { const info = await getJson(`${indexer.replace(/\/+$/, '')}/file/info/${root}`); const found = Boolean(info.ok && info.body && info.body.finalized !== undefined); + const txSeq = found ? seqOf(info.body) : null; return json({ indexer, root, found, finalized: found ? Boolean(info.body.finalized) : null, pruned: found ? Boolean(info.body.pruned) : null, + txSeq, info: info.body, - scanUrl: `${GALILEO.storageScan}/file/${root}`, + scanUrl: txSeq === null ? null : `${GALILEO.storageScan}/submission/${txSeq}`, }); } catch (err) { return json( diff --git a/public/js/main.js b/public/js/main.js index b6b9e30..4688f7d 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -781,22 +781,13 @@ async function uploadToStorage() { note.textContent = `0G Storage root ${shortHash(root)}${tx ? ` submit tx ${shortHash(String(tx))}` : ''}`; renderTracks({ storage: { uploaded: true, root } }); - const link = $('link-storage'); - link.href = `https://storagescan-galileo.0g.ai/file/${root}`; - link.classList.remove('is-disabled'); - link.removeAttribute('aria-disabled'); - - // 存完再跟 indexer 對一次,確認節點真的收下並完成同步 - try { - const info = await OG.storageInfo(root); - if (info && info.found) { - renderTracks({ storage: { uploaded: true, root, finalized: info.finalized } }); - note.textContent = `已存進 0G Storage${info.finalized ? '(已 finalized)' : '(同步中)'} root ${shortHash(root)}`; - if (info.scanUrl) link.href = info.scanUrl; - } - } catch { - // indexer 查詢失敗不影響「已上傳」這件事,畫面維持上一段的結果 - } + // 「在 0G Storage 上查看」要等真的查得到才開放。 + // + // 之前是上傳完就直接把連結指到 storagescan 的 /file/,結果點下去 404 —— + // 那個站根本沒有這條路徑,檔案頁是用提交序號定位的(/submission/), + // 而序號要跟 indexer 查了才知道。所以改成查到序號才解鎖按鈕;查不到就照實 + // 留成不可按,不要給玩家一個會 404 的連結。 + await pollStorage(root, note); } catch (err) { btn.textContent = '存進 0G Storage'; btn.disabled = false; @@ -809,6 +800,48 @@ async function uploadToStorage() { } } +/** + * 上傳完之後跟 indexer 要這個檔案的提交序號,拿到才解鎖 storagescan 連結。 + * + * 為什麼要輪詢:segment 傳完不等於索引好。節點要先把 log entry 同步進來, + * indexer 才答得出 tx.seq。實測這中間有幾秒到十幾秒,馬上查通常是空的。 + * + * 每一輪都照實更新畫面上那句話,不要讓玩家對著沒反應的按鈕猜。 + * 輪完還是沒有就維持不可按,並說明檔案已經上傳、只是還沒被索引到。 + */ +async function pollStorage(root, note, attempts = 6) { + const link = $('link-storage'); + + for (let i = 0; i < attempts; i++) { + let info = null; + try { + info = await OG.storageInfo(root); + } catch { + // indexer 查詢失敗不影響「已上傳」這件事,下一輪再試 + } + + if (info && info.found) { + renderTracks({ storage: { uploaded: true, root, finalized: info.finalized } }); + note.textContent = `已存進 0G Storage${info.finalized ? '(已 finalized)' : '(同步中)'} root ${shortHash(root)}`; + + if (info.scanUrl) { + link.href = info.scanUrl; + link.classList.remove('is-disabled'); + link.removeAttribute('aria-disabled'); + return true; + } + } + + if (i < attempts - 1) { + note.textContent = `已存進 0G Storage root ${shortHash(root)} 等待索引…(第 ${i + 1} 次查詢)`; + await sleep(3000); + } + } + + note.textContent = `已存進 0G Storage root ${shortHash(root)} storagescan 還沒索引到這筆,稍後可用 root 自行查詢`; + return false; +} + /** * 賽道三的收尾:把剛送出去的交易從 0G Chain 讀回來,重新解析 calldata 並比對摘要。 * 「錢包沒報錯」不算證明,讀得回來、摘要對得上才算。 From e3694e8bc385ed3e4a95cc79923e76181e52edec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:58:21 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E5=89=9D=E6=8E=89=20indexer=20=E5=9B=9E?= =?UTF-8?q?=E6=87=89=E7=9A=84=E4=BF=A1=E5=B0=81=20=E2=80=94=E2=80=94=20fou?= =?UTF-8?q?nd=20=E4=B8=80=E7=9B=B4=E6=98=AF=20false?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實際打了一次才發現答案包在一層 data 裡: {"code":0,"message":"Success","data":{"tx":{…,"seq":150104},"finalized":true,…}} 原本這支直接讀 body.finalized,那永遠是 undefined,所以 found 一直是 false。 「上傳完再跟 indexer 對一次,確認節點真的收下」這個確認靜靜地失敗了很久, 只是被上傳成功的畫面蓋過去,沒有人發現。 unwrap() 有 data 就剝掉、沒有就當作已經是內容本身(不同版本回過沒包信封的 形狀);code 不是 0 表示 indexer 自己說失敗,這時不把 data 當有效答案。 順便把檔案大小也一起回報。 用使用者實際打回來的那份 JSON 一個字不改地測: 剝殼後 finalized : true 提交序號 txSeq : 150104 檔案大小 : 745 bytes 產生的連結 : https://storagescan-galileo.0g.ai/submission/150104 另外四個形狀(舊版無信封、code 非 0、data 缺 tx、整包 null)也都符合預期。 這也確認了上一個 commit 的推測是對的:欄位就是 tx.seq。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3 --- functions/api/og/storage.js | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/functions/api/og/storage.js b/functions/api/og/storage.js index aedf28e..f4cd048 100644 --- a/functions/api/og/storage.js +++ b/functions/api/og/storage.js @@ -73,6 +73,24 @@ async function indexerNodes(indexer) { throw new Error(errors.join(' | ').slice(0, 160)); } +/** + * indexer 把答案包在一層信封裡: + * + * {"code":0,"message":"Success","data":{"tx":{…,"seq":150104},"finalized":true,…}} + * + * 原本這裡直接讀 body.finalized,那永遠是 undefined,於是 found 一直是 false —— + * 「跟 indexer 對一次」這個確認靜靜地失敗了很久,只是被上傳成功的畫面蓋過去。 + * 有 data 就剝掉,沒有就當作已經是內容本身(不同版本回過沒包信封的形狀)。 + * code 不是 0 代表 indexer 自己說失敗,這時不要把 data 當成有效答案。 + */ +function unwrap(body) { + if (!body || typeof body !== 'object') return null; + if (body.data && typeof body.data === 'object') { + return body.code === undefined || body.code === 0 ? body.data : null; + } + return body; +} + /** * storagescan 的檔案頁是用「提交序號」定位的,不是 root hash: * @@ -118,16 +136,18 @@ export async function onRequestGet({ request, env }) { try { const info = await getJson(`${indexer.replace(/\/+$/, '')}/file/info/${root}`); - const found = Boolean(info.ok && info.body && info.body.finalized !== undefined); - const txSeq = found ? seqOf(info.body) : null; + const file = unwrap(info.body); + const found = Boolean(info.ok && file && file.finalized !== undefined); + const txSeq = found ? seqOf(file) : null; return json({ indexer, root, found, - finalized: found ? Boolean(info.body.finalized) : null, - pruned: found ? Boolean(info.body.pruned) : null, + finalized: found ? Boolean(file.finalized) : null, + pruned: found ? Boolean(file.pruned) : null, + size: found && Number.isFinite(Number(file.tx && file.tx.size)) ? Number(file.tx.size) : null, txSeq, - info: info.body, + info: file, scanUrl: txSeq === null ? null : `${GALILEO.storageScan}/submission/${txSeq}`, }); } catch (err) {