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..f4cd048 100644 --- a/functions/api/og/storage.js +++ b/functions/api/og/storage.js @@ -73,6 +73,47 @@ 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: + * + * 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(); @@ -95,15 +136,19 @@ 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 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, - info: info.body, - scanUrl: `${GALILEO.storageScan}/file/${root}`, + 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: file, + 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 並比對摘要。 * 「錢包沒報錯」不算證明,讀得回來、摘要對得上才算。