From 98c95d483cde34d5fbbe5310629d8051ab48bbe3 Mon Sep 17 00:00:00 2001 From: Matee ullah Malik <46045452+mateeullahmalik@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:26:32 +0000 Subject: [PATCH] fix(cascade): preserve snapi base path and robust status polling - Fix availability_commitment.total_size serialization: emit JSON number with safe bigint guard\n- Preserve baseUrl path prefixes (e.g. /proxy/snapi) when building request URLs\n- Poll task history for status instead of SSE /status endpoint, with compatibility fallback --- src/cascade/client.ts | 25 +++++++++++++++---- src/cascade/uploader.ts | 53 ++++++++++++++++++++++++++++++++++------- src/internal/http.ts | 9 ++++++- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/cascade/client.ts b/src/cascade/client.ts index 1d2b1b1..c63ae00 100644 --- a/src/cascade/client.ts +++ b/src/cascade/client.ts @@ -229,14 +229,31 @@ export class SNApiClient { * ``` */ async getTaskStatus(taskId: string): Promise { - // Prefer the versioned path; fall back to legacy/non-versioned path on 404 + // `/status` is SSE on sn-api-server and can block when polled via fetch text. + // Use `/history` for polling and return the latest status entry. try { - return await this.http.get(`/api/v1/actions/cascade/tasks/${taskId}/status`); + const history = await this.http.get>>(`/api/v1/actions/cascade/tasks/${taskId}/history`); + if (Array.isArray(history) && history.length > 0) { + return history[history.length - 1] as unknown as TaskStatus; + } } catch (err) { - if (err instanceof HttpError && err.statusCode === 404) { + if (!(err instanceof HttpError && err.statusCode === 404)) { + throw err; + } + const legacyHistory = await this.http.get>>(`/api/actions/cascade/tasks/${taskId}/history`); + if (Array.isArray(legacyHistory) && legacyHistory.length > 0) { + return legacyHistory[legacyHistory.length - 1] as unknown as TaskStatus; + } + } + + // Fallback to explicit status endpoint for deployments where it is plain JSON. + try { + return await this.http.get(`/api/v1/actions/cascade/tasks/${taskId}/status`); + } catch (statusErr) { + if (statusErr instanceof HttpError && statusErr.statusCode === 404) { return this.http.get(`/api/actions/cascade/tasks/${taskId}/status`); } - throw err; + throw statusErr; } } diff --git a/src/cascade/uploader.ts b/src/cascade/uploader.ts index c43c879..a5c14f7 100644 --- a/src/cascade/uploader.ts +++ b/src/cascade/uploader.ts @@ -33,6 +33,8 @@ import { toBase64, toCanonicalJsonBytes } from '../internal/encoding'; import { createSingleBlockLayout, generateIds, buildIndexFile } from '../wasm/lep1'; import type { UniversalSigner, ArbitrarySignResponse } from '../wallets/signer'; import { createDefaultSignaturePrompter } from '../wallets/prompter'; +import { buildCommitment, DEFAULT_SVC_CHALLENGE_COUNT, DEFAULT_SVC_MIN_CHUNKS_FOR_CHALLENGE } from './commitment'; +import type { AvailabilityCommitment } from '../codegen/lumera/action/v1/metadata'; export type CascadeSignatureKind = "layout" | "index" | "auth"; @@ -237,7 +239,9 @@ export class CascadeUploader { // Step 1: Get action params from blockchain const actionParams = await this.chainPort.getActionParams(); const rq_ids_max = actionParams.max_raptor_q_symbols; - console.debug('CascadeUploader.registerAction actionParams', { actionParams }); + const svcChallengeCount = actionParams.svc_challenge_count || DEFAULT_SVC_CHALLENGE_COUNT; + const svcMinChunks = actionParams.svc_min_chunks_for_challenge || DEFAULT_SVC_MIN_CHUNKS_FOR_CHALLENGE; + console.debug('CascadeUploader.registerAction actionParams', { actionParams, svcChallengeCount, svcMinChunks }); // Step 2: Generate random initial counter for layout ID derivation const rq_ids_ic = Math.floor(Math.random() * rq_ids_max); @@ -302,6 +306,18 @@ export class CascadeUploader { const indexWithSignature = `${indexFileB64}.${indexSignatureResponse.signature}`; console.debug('CascadeUploader.registerAction indexWithSignature', { indexWithSignature }); + // Step 7b: Build LEP-5 availability commitment (Merkle tree over file chunks) + let availabilityCommitment: AvailabilityCommitment | undefined; + const commitmentResult = await buildCommitment(fileBytes, svcChallengeCount, svcMinChunks); + if (commitmentResult) { + availabilityCommitment = commitmentResult.commitment; + console.debug('CascadeUploader.registerAction built availability commitment', { + chunkSize: availabilityCommitment.chunkSize, + numChunks: availabilityCommitment.numChunks, + challengeIndices: availabilityCommitment.challengeIndices, + }); + } + // Step 8: Prepare auth_signature for upload const authSignatureResponse = await this.requestSignature( "auth", @@ -313,14 +329,35 @@ export class CascadeUploader { console.debug('CascadeUploader.registerAction authSignature', { authSignature }); // Step 9: Register the action on-chain + const msg: Record = { + data_hash: dataHash, + file_name: params.fileName, + rq_ids_ic, + signatures: indexWithSignature, + public: params.isPublic, + }; + if (availabilityCommitment) { + msg.availability_commitment = { + commitment_type: availabilityCommitment.commitmentType, + hash_algo: availabilityCommitment.hashAlgo, + chunk_size: availabilityCommitment.chunkSize, + total_size: (() => { + const v = availabilityCommitment.totalSize; + if (typeof v === "bigint") { + if (v > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`availability_commitment.total_size exceeds Number.MAX_SAFE_INTEGER: ${v.toString()}`); + } + return Number(v); + } + return v; + })(), + num_chunks: availabilityCommitment.numChunks, + root: Array.from(availabilityCommitment.root), + challenge_indices: availabilityCommitment.challengeIndices, + }; + } const txOutcome = await this.chainPort.requestActionTx({ - msg: { - data_hash: dataHash, - file_name: params.fileName, - rq_ids_ic, - signatures: indexWithSignature, - public: params.isPublic, - }, + msg, expirationTime: params.expirationTime, txPrompter: params.txPrompter, }, fileBytes.length); diff --git a/src/internal/http.ts b/src/internal/http.ts index 98004ef..a986878 100644 --- a/src/internal/http.ts +++ b/src/internal/http.ts @@ -600,7 +600,14 @@ export class HttpClient { * @returns Full URL */ private buildUrl(path: string, params?: Record): string { - const url = new URL(path, this.config.baseUrl); + const base = new URL(this.config.baseUrl); + const baseHasPath = base.pathname !== '' && base.pathname !== '/'; + + // When baseUrl contains a path prefix (e.g. /proxy/snapi), absolute request + // paths like "/api/v1/..." would otherwise drop that prefix. Preserve it. + const resolvedPath = baseHasPath && path.startsWith('/') ? path.slice(1) : path; + const baseHref = this.config.baseUrl.endsWith('/') ? this.config.baseUrl : `${this.config.baseUrl}/`; + const url = new URL(resolvedPath, baseHref); if (params) { Object.entries(params).forEach(([key, value]) => {