From fd491d0de9d9abbaaf8b5bbc2f66397920816f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 7 Jan 2026 12:06:28 +0100 Subject: [PATCH 01/23] feat: show remaining CDN & cache-miss egress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- .../warm-storage/data-sets-section.tsx | 40 ++++++++++++---- apps/synapse-playground/src/lib/utils.ts | 12 +++++ .../src/warm-storage/use-data-sets.ts | 48 +++++++++++++++++++ 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx index 8354bb2f3..eab15d411 100644 --- a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx +++ b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx @@ -1,9 +1,9 @@ import type { DataSetWithPieces, UseProvidersResult } from '@filoz/synapse-react' import { useDeletePiece } from '@filoz/synapse-react' -import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Globe, Info, Trash } from 'lucide-react' +import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Globe, Info, RefreshCw, Trash } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' -import { toastError } from '@/lib/utils.ts' +import { formatBytes, toastError } from '@/lib/utils.ts' import { ButtonLoading } from '../custom-ui/button-loading.tsx' import { ExplorerLink } from '../explorer-link.tsx' import { PDPDatasetLink, PDPPieceLink, PDPProviderLink } from '../pdp-link.tsx' @@ -110,14 +110,34 @@ export function DataSetsSection({ {dataSet.cdn && ( - - - - - -

This data set is using CDN

-
-
+ <> + + + + {dataSet.egressQuota && ( + + {formatBytes(dataSet.egressQuota.cdnEgressQuota)} + + )} + + +

CDN egress quota remaining

+
+
+ {dataSet.egressQuota && ( + + + + + {formatBytes(dataSet.egressQuota.cacheMissEgressQuota)} + + + +

Cache miss egress quota remaining

+
+
+ )} + )}

diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts index e9b1f1b1d..41237aba6 100644 --- a/apps/synapse-playground/src/lib/utils.ts +++ b/apps/synapse-playground/src/lib/utils.ts @@ -41,3 +41,15 @@ export function toastError(error: Error, id: string, title?: string) { id, }) } + +export function formatBytes(bytes: bigint | number): string { + const num = typeof bytes === 'bigint' ? Number(bytes) : bytes + if (num === 0) return '0 B' + + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const k = 1024 + const i = Math.floor(Math.log(num) / Math.log(k)) + const value = num / Math.pow(k, i) + + return `${value.toFixed(2).replace(/\.?0+$/, '')} ${units[i]}` +} diff --git a/packages/synapse-react/src/warm-storage/use-data-sets.ts b/packages/synapse-react/src/warm-storage/use-data-sets.ts index de49441f7..df27c4cda 100644 --- a/packages/synapse-react/src/warm-storage/use-data-sets.ts +++ b/packages/synapse-react/src/warm-storage/use-data-sets.ts @@ -9,8 +9,51 @@ import { useChainId, useConfig } from 'wagmi' export type PieceWithMetadata = Simplify +export interface DataSetEgressQuota { + cdnEgressQuota: bigint + cacheMissEgressQuota: bigint +} + export interface DataSetWithPieces extends DataSet { pieces: PieceWithMetadata[] + egressQuota?: DataSetEgressQuota +} + +function getFilBeamStatsBaseUrl(chainId: number): string { + return chainId === 314 ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' +} + +async function fetchDataSetEgressQuota( + chainId: number, + dataSetId: bigint +): Promise { + const baseUrl = getFilBeamStatsBaseUrl(chainId) + const url = `${baseUrl}/data-set/${dataSetId}` + + try { + const response = await fetch(url) + + if (response.status === 404) { + return undefined + } + + if (response.status !== 200) { + return undefined + } + + const data = (await response.json()) as Record + + if (typeof data.cdnEgressQuota !== 'string' || typeof data.cacheMissEgressQuota !== 'string') { + return undefined + } + + return { + cdnEgressQuota: BigInt(data.cdnEgressQuota), + cacheMissEgressQuota: BigInt(data.cacheMissEgressQuota), + } + } catch { + return undefined + } } export type UseDataSetsResult = DataSetWithPieces[] @@ -52,9 +95,14 @@ export function useDataSets(props: UseDataSetsProps) { }) ) + const egressQuota = dataSet.cdn + ? await fetchDataSetEgressQuota(chainId, dataSet.dataSetId) + : undefined + return { ...dataSet, pieces: piecesWithMetadata, + egressQuota, } }) ) From 9aa16b772f1b623a886208b13a4a44269cf6157b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 7 Jan 2026 12:09:16 +0100 Subject: [PATCH 02/23] chore: document synapse-react in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- AGENTS.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index da06fbf6d..2be4b3fa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,19 @@ packages/synapse-sdk/src/ **Data flow**: Client signs for FWSS → Curio HTTP API → PDPVerifier contract → FWSS callback → Payments contract. +## Monorepo Package Structure + +**synapse-core** (`packages/synapse-core/`): Low-level utilities, types, chain definitions, and blockchain interactions using viem. Shared foundation for both synapse-react and synapse-sdk. + +**synapse-react** (`packages/synapse-react/`): React hooks wrapping synapse-core for React apps. Uses wagmi + @tanstack/react-query. Does NOT depend on synapse-sdk. + +**synapse-sdk** (`packages/synapse-sdk/`): High-level SDK using ethers.js. Includes services like FilBeamService. For Node.js and browser script usage. + +**synapse-playground** (`apps/synapse-playground/`): Vite-based React demo app using synapse-react hooks. Dev server runs at localhost:5173. + ## Development -**Monorepo**: pnpm workspace, packages in `packages/*`, examples in `examples/*` +**Monorepo**: pnpm workspace, packages in `packages/*`, apps in `apps/*`, examples in `examples/*` **Commands**: From 2132a055d2220c4037d51b4c34a978b742cbdaa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 7 Jan 2026 12:25:58 +0100 Subject: [PATCH 03/23] fixup! cache-miss icon RefreshCW -> FolderSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- .../src/components/warm-storage/data-sets-section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx index eab15d411..8eb252c87 100644 --- a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx +++ b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx @@ -1,6 +1,6 @@ import type { DataSetWithPieces, UseProvidersResult } from '@filoz/synapse-react' import { useDeletePiece } from '@filoz/synapse-react' -import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Globe, Info, RefreshCw, Trash } from 'lucide-react' +import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, FolderSync, Globe, Info, Trash } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { formatBytes, toastError } from '@/lib/utils.ts' @@ -127,7 +127,7 @@ export function DataSetsSection({ {dataSet.egressQuota && ( - + {formatBytes(dataSet.egressQuota.cacheMissEgressQuota)} From f029bc49265a3a136370286fc36b1a56d33d2727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 7 Jan 2026 12:26:29 +0100 Subject: [PATCH 04/23] fixup! npm run lint:fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- apps/synapse-playground/src/lib/utils.ts | 2 +- packages/synapse-react/src/warm-storage/use-data-sets.ts | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts index 41237aba6..a00f03360 100644 --- a/apps/synapse-playground/src/lib/utils.ts +++ b/apps/synapse-playground/src/lib/utils.ts @@ -49,7 +49,7 @@ export function formatBytes(bytes: bigint | number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB'] const k = 1024 const i = Math.floor(Math.log(num) / Math.log(k)) - const value = num / Math.pow(k, i) + const value = num / k ** i return `${value.toFixed(2).replace(/\.?0+$/, '')} ${units[i]}` } diff --git a/packages/synapse-react/src/warm-storage/use-data-sets.ts b/packages/synapse-react/src/warm-storage/use-data-sets.ts index df27c4cda..8c554bb8e 100644 --- a/packages/synapse-react/src/warm-storage/use-data-sets.ts +++ b/packages/synapse-react/src/warm-storage/use-data-sets.ts @@ -23,10 +23,7 @@ function getFilBeamStatsBaseUrl(chainId: number): string { return chainId === 314 ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' } -async function fetchDataSetEgressQuota( - chainId: number, - dataSetId: bigint -): Promise { +async function fetchDataSetEgressQuota(chainId: number, dataSetId: bigint): Promise { const baseUrl = getFilBeamStatsBaseUrl(chainId) const url = `${baseUrl}/data-set/${dataSetId}` @@ -95,9 +92,7 @@ export function useDataSets(props: UseDataSetsProps) { }) ) - const egressQuota = dataSet.cdn - ? await fetchDataSetEgressQuota(chainId, dataSet.dataSetId) - : undefined + const egressQuota = dataSet.cdn ? await fetchDataSetEgressQuota(chainId, dataSet.dataSetId) : undefined return { ...dataSet, From b1f0f04015b35612cfba41e23a95c423a72e5957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 7 Jan 2026 12:30:32 +0100 Subject: [PATCH 05/23] fixup! code cleanup + better error logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- .../synapse-react/src/warm-storage/use-data-sets.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/synapse-react/src/warm-storage/use-data-sets.ts b/packages/synapse-react/src/warm-storage/use-data-sets.ts index 8c554bb8e..04e3e2709 100644 --- a/packages/synapse-react/src/warm-storage/use-data-sets.ts +++ b/packages/synapse-react/src/warm-storage/use-data-sets.ts @@ -29,18 +29,15 @@ async function fetchDataSetEgressQuota(chainId: number, dataSetId: bigint): Prom try { const response = await fetch(url) - - if (response.status === 404) { - return undefined - } - - if (response.status !== 200) { + if (!response.ok) { + console.error(`Failed to fetch data set egress quota: ${response.status} ${response.statusText}`) return undefined } const data = (await response.json()) as Record if (typeof data.cdnEgressQuota !== 'string' || typeof data.cacheMissEgressQuota !== 'string') { + console.error('Unexpected response body from FilBeam Stats API:', data) return undefined } @@ -49,6 +46,7 @@ async function fetchDataSetEgressQuota(chainId: number, dataSetId: bigint): Prom cacheMissEgressQuota: BigInt(data.cacheMissEgressQuota), } } catch { + console.error('Cannot fetch data set egress quotas from FilBeam Stats API:', err) return undefined } } From 10c3811f4c2c4bf996b02a0156403036a28cef3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 7 Jan 2026 12:38:40 +0100 Subject: [PATCH 06/23] fixup! forgotten err MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- packages/synapse-react/src/warm-storage/use-data-sets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/synapse-react/src/warm-storage/use-data-sets.ts b/packages/synapse-react/src/warm-storage/use-data-sets.ts index 04e3e2709..8fb4b5312 100644 --- a/packages/synapse-react/src/warm-storage/use-data-sets.ts +++ b/packages/synapse-react/src/warm-storage/use-data-sets.ts @@ -45,8 +45,8 @@ async function fetchDataSetEgressQuota(chainId: number, dataSetId: bigint): Prom cdnEgressQuota: BigInt(data.cdnEgressQuota), cacheMissEgressQuota: BigInt(data.cacheMissEgressQuota), } - } catch { - console.error('Cannot fetch data set egress quotas from FilBeam Stats API:', err) + } catch (err) { + console.error('Cannot fetch data set egress quotas from FilBeam Stats API', err) return undefined } } From ac134f76ec5ff385b6a03c0f91f21add2b2de6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 13 Jan 2026 11:02:33 +0100 Subject: [PATCH 07/23] fix(playground): improve egress quota display in Data Sets UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change format to "Egress remaining: X GiB delivery · Y GiB cache-miss" - Remove FolderSync icon, keep Globe icon with tooltip "This data set is using CDN" - Use binary units (KiB, MiB, GiB, TiB) instead of decimal units Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- .../warm-storage/data-sets-section.tsx | 30 ++++++------------- apps/synapse-playground/src/lib/utils.ts | 2 +- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx index 8eb252c87..cbd23f2f4 100644 --- a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx +++ b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx @@ -1,6 +1,6 @@ import type { DataSetWithPieces, UseProvidersResult } from '@filoz/synapse-react' import { useDeletePiece } from '@filoz/synapse-react' -import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, FolderSync, Globe, Info, Trash } from 'lucide-react' +import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Globe, Info, Trash } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { formatBytes, toastError } from '@/lib/utils.ts' @@ -110,34 +110,22 @@ export function DataSetsSection({ {dataSet.cdn && ( - <> + - + - {dataSet.egressQuota && ( - - {formatBytes(dataSet.egressQuota.cdnEgressQuota)} - - )} -

CDN egress quota remaining

+

This data set is using CDN

{dataSet.egressQuota && ( - - - - - {formatBytes(dataSet.egressQuota.cacheMissEgressQuota)} - - - -

Cache miss egress quota remaining

-
-
+ <> + Egress remaining: {formatBytes(dataSet.egressQuota.cdnEgressQuota)} delivery {' · '} + {formatBytes(dataSet.egressQuota.cacheMissEgressQuota)} cache-miss + )} - +
)}

diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts index a00f03360..831bfe7fb 100644 --- a/apps/synapse-playground/src/lib/utils.ts +++ b/apps/synapse-playground/src/lib/utils.ts @@ -46,7 +46,7 @@ export function formatBytes(bytes: bigint | number): string { const num = typeof bytes === 'bigint' ? Number(bytes) : bytes if (num === 0) return '0 B' - const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] const k = 1024 const i = Math.floor(Math.log(num) / Math.log(k)) const value = num / k ** i From ad49466fe427ed985fe03f3189fd7b7e6128ed94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 14 Jan 2026 11:31:05 +0100 Subject: [PATCH 08/23] refactor: move FilBeam egress quota logic to synapse-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add filbeam module to synapse-core with getDataSetStats function - Use iso-web/http request lib with proper error handling - Add GetDataSetStatsError class for FilBeam API errors - Add parseBigInt validation for API response values - Create useEgressQuota hook in synapse-react - Remove egress quota fetching from useDataSets hook - Update playground to use new useEgressQuota hook Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- .../warm-storage/data-sets-section.tsx | 24 +++- packages/synapse-core/package.json | 7 + packages/synapse-core/src/errors/filbeam.ts | 13 ++ packages/synapse-core/src/errors/index.ts | 1 + packages/synapse-core/src/filbeam/index.ts | 11 ++ packages/synapse-core/src/filbeam/stats.ts | 122 ++++++++++++++++++ .../synapse-react/src/warm-storage/index.ts | 1 + .../src/warm-storage/use-data-sets.ts | 41 ------ .../src/warm-storage/use-egress-quota.ts | 45 +++++++ packages/synapse-sdk/src/filbeam/service.ts | 9 +- 10 files changed, 223 insertions(+), 51 deletions(-) create mode 100644 packages/synapse-core/src/errors/filbeam.ts create mode 100644 packages/synapse-core/src/filbeam/index.ts create mode 100644 packages/synapse-core/src/filbeam/stats.ts create mode 100644 packages/synapse-react/src/warm-storage/use-egress-quota.ts diff --git a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx index cbd23f2f4..afd2a19c6 100644 --- a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx +++ b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx @@ -1,5 +1,5 @@ import type { DataSetWithPieces, UseProvidersResult } from '@filoz/synapse-react' -import { useDeletePiece } from '@filoz/synapse-react' +import { useDeletePiece, useEgressQuota } from '@filoz/synapse-react' import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Globe, Info, Trash } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' @@ -14,6 +14,21 @@ import { Skeleton } from '../ui/skeleton.tsx' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.tsx' import { CreateDataSetDialog } from './create-data-set.tsx' +function EgressQuotaDisplay({ dataSetId }: { dataSetId: bigint }) { + const { data: egressQuota } = useEgressQuota({ dataSetId }) + + if (!egressQuota) { + return null + } + + return ( + <> + Egress remaining: {formatBytes(egressQuota.cdnEgressQuota)} delivery {' · '} + {formatBytes(egressQuota.cacheMissEgressQuota)} cache-miss + + ) +} + export function DataSetsSection({ dataSets, providers, @@ -119,12 +134,7 @@ export function DataSetsSection({

This data set is using CDN

- {dataSet.egressQuota && ( - <> - Egress remaining: {formatBytes(dataSet.egressQuota.cdnEgressQuota)} delivery {' · '} - {formatBytes(dataSet.egressQuota.cacheMissEgressQuota)} cache-miss - - )} + )}

diff --git a/packages/synapse-core/package.json b/packages/synapse-core/package.json index 4db809c78..993f15589 100644 --- a/packages/synapse-core/package.json +++ b/packages/synapse-core/package.json @@ -72,6 +72,10 @@ "types": "./dist/src/errors/index.d.ts", "default": "./dist/src/errors/index.js" }, + "./filbeam": { + "types": "./dist/src/filbeam/index.d.ts", + "default": "./dist/src/filbeam/index.js" + }, "./piece": { "types": "./dist/src/piece.d.ts", "default": "./dist/src/piece.js" @@ -120,6 +124,9 @@ "errors": [ "./dist/src/errors/index" ], + "filbeam": [ + "./dist/src/filbeam/index" + ], "piece": [ "./dist/src/piece" ], diff --git a/packages/synapse-core/src/errors/filbeam.ts b/packages/synapse-core/src/errors/filbeam.ts new file mode 100644 index 000000000..7760b6fc2 --- /dev/null +++ b/packages/synapse-core/src/errors/filbeam.ts @@ -0,0 +1,13 @@ +import { isSynapseError, SynapseError } from './base.ts' + +export class GetDataSetStatsError extends SynapseError { + override name: 'GetDataSetStatsError' = 'GetDataSetStatsError' + + constructor(message: string, details?: string) { + super(message, { details }) + } + + static override is(value: unknown): value is GetDataSetStatsError { + return isSynapseError(value) && value.name === 'GetDataSetStatsError' + } +} diff --git a/packages/synapse-core/src/errors/index.ts b/packages/synapse-core/src/errors/index.ts index 0fc26f89e..8c7239154 100644 --- a/packages/synapse-core/src/errors/index.ts +++ b/packages/synapse-core/src/errors/index.ts @@ -11,5 +11,6 @@ export * from './base.ts' export * from './chains.ts' export * from './erc20.ts' +export * from './filbeam.ts' export * from './pay.ts' export * from './pdp.ts' diff --git a/packages/synapse-core/src/filbeam/index.ts b/packages/synapse-core/src/filbeam/index.ts new file mode 100644 index 000000000..9e2963c47 --- /dev/null +++ b/packages/synapse-core/src/filbeam/index.ts @@ -0,0 +1,11 @@ +/** + * FilBeam API + * + * @example + * ```ts + * import * as FilBeam from '@filoz/synapse-core/filbeam' + * ``` + * + * @module filbeam + */ +export * from './stats.ts' diff --git a/packages/synapse-core/src/filbeam/stats.ts b/packages/synapse-core/src/filbeam/stats.ts new file mode 100644 index 000000000..548e3ed38 --- /dev/null +++ b/packages/synapse-core/src/filbeam/stats.ts @@ -0,0 +1,122 @@ +/** + * FilBeam Stats API + * + * @example + * ```ts + * import { getDataSetStats } from '@filoz/synapse-core/filbeam' + * ``` + * + * @module filbeam + */ + +import { HttpError, request } from 'iso-web/http' +import { GetDataSetStatsError } from '../errors/filbeam.ts' + +/** + * Data set statistics from FilBeam. + * + * These quotas represent the remaining pay-per-byte allocation available for data retrieval + * through FilBeam's trusted measurement layer. The values decrease as data is served and + * represent how many bytes can still be retrieved before needing to add more credits. + */ +export interface DataSetStats { + /** The remaining CDN egress quota for cache hits (data served directly from FilBeam's cache) in bytes */ + cdnEgressQuota: bigint + /** The remaining egress quota for cache misses (data retrieved from storage providers) in bytes */ + cacheMissEgressQuota: bigint +} + +/** + * Get the base stats URL for a given chain ID + */ +export function getStatsBaseUrl(chainId: number): string { + return chainId === 314 ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' +} + +/** + * Validates that a string can be converted to a valid BigInt + */ +function parseBigInt(value: string, fieldName: string): bigint { + // Check if the string is a valid integer format (optional minus sign followed by digits) + if (!/^-?\d+$/.test(value)) { + throw new GetDataSetStatsError('Invalid response format', `${fieldName} is not a valid integer: "${value}"`) + } + return BigInt(value) +} + +/** + * Validates the response from FilBeam stats API and returns DataSetStats + */ +function validateStatsResponse(data: unknown): DataSetStats { + if (typeof data !== 'object' || data === null) { + throw new GetDataSetStatsError('Invalid response format', 'Response is not an object') + } + + const response = data as Record + + if (typeof response.cdnEgressQuota !== 'string') { + throw new GetDataSetStatsError('Invalid response format', 'cdnEgressQuota must be a string') + } + + if (typeof response.cacheMissEgressQuota !== 'string') { + throw new GetDataSetStatsError('Invalid response format', 'cacheMissEgressQuota must be a string') + } + + return { + cdnEgressQuota: parseBigInt(response.cdnEgressQuota, 'cdnEgressQuota'), + cacheMissEgressQuota: parseBigInt(response.cacheMissEgressQuota, 'cacheMissEgressQuota'), + } +} + +export interface GetDataSetStatsOptions { + /** The chain ID (314 for mainnet, 314159 for calibration) */ + chainId: number + /** The data set ID to query */ + dataSetId: bigint | number | string +} + +/** + * Retrieves remaining pay-per-byte statistics for a specific data set from FilBeam. + * + * Fetches the remaining CDN and cache miss egress quotas for a data set. These quotas + * track how many bytes can still be retrieved through FilBeam's trusted measurement layer + * before needing to add more credits: + * + * - **CDN Egress Quota**: Remaining bytes that can be served from FilBeam's cache (fast, direct delivery) + * - **Cache Miss Egress Quota**: Remaining bytes that can be retrieved from storage providers (triggers caching) + * + * @param options - The options for fetching data set stats + * @returns A promise that resolves to the data set statistics with remaining quotas as BigInt values + * + * @throws {GetDataSetStatsError} If the data set is not found, the API returns an invalid response, or network errors occur + * + * @example + * ```typescript + * const stats = await getDataSetStats({ chainId: 314, dataSetId: 12345n }) + * console.log(`Remaining CDN Egress: ${stats.cdnEgressQuota} bytes`) + * console.log(`Remaining Cache Miss: ${stats.cacheMissEgressQuota} bytes`) + * ``` + */ +export async function getDataSetStats(options: GetDataSetStatsOptions): Promise { + const baseUrl = getStatsBaseUrl(options.chainId) + const url = `${baseUrl}/data-set/${options.dataSetId}` + + const response = await request.json.get(url) + + if (response.error) { + if (HttpError.is(response.error)) { + const status = response.error.response.status + if (status === 404) { + throw new GetDataSetStatsError(`Data set not found: ${options.dataSetId}`) + } + const errorText = await response.error.response.text().catch(() => 'Unknown error') + throw new GetDataSetStatsError( + `Failed to fetch data set stats`, + `HTTP ${status} ${response.error.response.statusText}: ${errorText}` + ) + } + throw response.error + } + + return validateStatsResponse(response.result) +} diff --git a/packages/synapse-react/src/warm-storage/index.ts b/packages/synapse-react/src/warm-storage/index.ts index afe8eab15..4dfcae2c8 100644 --- a/packages/synapse-react/src/warm-storage/index.ts +++ b/packages/synapse-react/src/warm-storage/index.ts @@ -1,6 +1,7 @@ export * from './use-create-data-set.ts' export * from './use-data-sets.ts' export * from './use-delete-piece.ts' +export * from './use-egress-quota.ts' export * from './use-providers.ts' export * from './use-service-price.ts' export * from './use-upload.ts' diff --git a/packages/synapse-react/src/warm-storage/use-data-sets.ts b/packages/synapse-react/src/warm-storage/use-data-sets.ts index 8fb4b5312..de49441f7 100644 --- a/packages/synapse-react/src/warm-storage/use-data-sets.ts +++ b/packages/synapse-react/src/warm-storage/use-data-sets.ts @@ -9,46 +9,8 @@ import { useChainId, useConfig } from 'wagmi' export type PieceWithMetadata = Simplify -export interface DataSetEgressQuota { - cdnEgressQuota: bigint - cacheMissEgressQuota: bigint -} - export interface DataSetWithPieces extends DataSet { pieces: PieceWithMetadata[] - egressQuota?: DataSetEgressQuota -} - -function getFilBeamStatsBaseUrl(chainId: number): string { - return chainId === 314 ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' -} - -async function fetchDataSetEgressQuota(chainId: number, dataSetId: bigint): Promise { - const baseUrl = getFilBeamStatsBaseUrl(chainId) - const url = `${baseUrl}/data-set/${dataSetId}` - - try { - const response = await fetch(url) - if (!response.ok) { - console.error(`Failed to fetch data set egress quota: ${response.status} ${response.statusText}`) - return undefined - } - - const data = (await response.json()) as Record - - if (typeof data.cdnEgressQuota !== 'string' || typeof data.cacheMissEgressQuota !== 'string') { - console.error('Unexpected response body from FilBeam Stats API:', data) - return undefined - } - - return { - cdnEgressQuota: BigInt(data.cdnEgressQuota), - cacheMissEgressQuota: BigInt(data.cacheMissEgressQuota), - } - } catch (err) { - console.error('Cannot fetch data set egress quotas from FilBeam Stats API', err) - return undefined - } } export type UseDataSetsResult = DataSetWithPieces[] @@ -90,12 +52,9 @@ export function useDataSets(props: UseDataSetsProps) { }) ) - const egressQuota = dataSet.cdn ? await fetchDataSetEgressQuota(chainId, dataSet.dataSetId) : undefined - return { ...dataSet, pieces: piecesWithMetadata, - egressQuota, } }) ) diff --git a/packages/synapse-react/src/warm-storage/use-egress-quota.ts b/packages/synapse-react/src/warm-storage/use-egress-quota.ts new file mode 100644 index 000000000..e278b1f45 --- /dev/null +++ b/packages/synapse-react/src/warm-storage/use-egress-quota.ts @@ -0,0 +1,45 @@ +import { type DataSetStats, getDataSetStats } from '@filoz/synapse-core/filbeam' +import { skipToken, type UseQueryOptions, useQuery } from '@tanstack/react-query' +import { useChainId } from 'wagmi' + +export type { DataSetStats } + +/** + * The props for the useEgressQuota hook. + */ +export interface UseEgressQuotaProps { + /** The data set ID to query egress quota for */ + dataSetId?: bigint + query?: Omit, 'queryKey' | 'queryFn'> +} + +/** + * The result for the useEgressQuota hook. + */ +export type UseEgressQuotaResult = DataSetStats + +/** + * Get the egress quota for a data set from FilBeam. + * + * @param props - The props to use. + * @returns The egress quota for the data set. + * + * @example + * ```tsx + * const { data: egressQuota, isLoading } = useEgressQuota({ dataSetId: 123n }) + * if (egressQuota) { + * console.log(`CDN Egress: ${egressQuota.cdnEgressQuota}`) + * console.log(`Cache Miss Egress: ${egressQuota.cacheMissEgressQuota}`) + * } + * ``` + */ +export function useEgressQuota(props: UseEgressQuotaProps) { + const chainId = useChainId() + const dataSetId = props.dataSetId + + return useQuery({ + ...props.query, + queryKey: ['synapse-filbeam-egress-quota', chainId, dataSetId?.toString()], + queryFn: dataSetId != null ? () => getDataSetStats({ chainId, dataSetId }) : skipToken, + }) +} diff --git a/packages/synapse-sdk/src/filbeam/service.ts b/packages/synapse-sdk/src/filbeam/service.ts index 3b7853c4a..f10ea19f2 100644 --- a/packages/synapse-sdk/src/filbeam/service.ts +++ b/packages/synapse-sdk/src/filbeam/service.ts @@ -78,7 +78,10 @@ export class FilBeamService { /** * Validates the response from FilBeam stats API */ - private _validateStatsResponse(data: unknown): { cdnEgressQuota: string; cacheMissEgressQuota: string } { + private _validateStatsResponse(data: unknown): { + cdnEgressQuota: string + cacheMissEgressQuota: string + } { if (typeof data !== 'object' || data === null) { throw createError('FilBeamService', 'validateStatsResponse', 'Response is not an object') } @@ -106,8 +109,8 @@ export class FilBeamService { * track how many bytes can still be retrieved through FilBeam's trusted measurement layer * before needing to add more credits: * - * - **CDN Egress Quota**: Remaining bytes that can be served from FilBeam's cache (fast, direct delivery) - * - **Cache Miss Egress Quota**: Remaining bytes that can be retrieved from storage providers (triggers caching) + * - **CDN Egress Quota**: Remaining bytes that can be served by FilBeam (both cache-hit and cache-miss requests) + * - **Cache Miss Egress Quota**: Remaining bytes that can be retrieved from storage providers (cache-miss requests to origin) * * Both types of egress are billed based on volume. Query current pricing via * {@link WarmStorageService.getServicePrice} or see https://docs.filbeam.com for rates. From 164bc701f071e5e71c4742a993905d999dd5ce4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 14 Jan 2026 12:19:27 +0100 Subject: [PATCH 09/23] test(synapse-core): add tests for FilBeam stats module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests for getStatsBaseUrl and validateStatsResponse functions. Export validateStatsResponse to enable direct testing without HTTP mocking. Also update AGENTS.md with testing guidelines: - Use assert.deepStrictEqual for object comparisons - Use parameterized tests with descriptive names Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- AGENTS.md | 41 ++++++++++++ packages/synapse-core/src/filbeam/stats.ts | 2 +- packages/synapse-core/test/filbeam.test.ts | 76 ++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/synapse-core/test/filbeam.test.ts diff --git a/AGENTS.md b/AGENTS.md index 2be4b3fa2..5ac9166f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,47 @@ packages/synapse-sdk/src/ **Tests**: Mocha + Chai, `src/test/`, run with `pnpm test` +When working on a single package, run the tests for that package only. For +example: + +```bash +pnpm -F ./packages/synapse-core test +``` + +**Test assertions**: Use `assert.deepStrictEqual` for comparing objects instead of multiple `assert.equal` calls: + +```typescript +// Bad +assert.equal(result.cdnEgressQuota, 1000000n) +assert.equal(result.cacheMissEgressQuota, 500000n) + +// Good +assert.deepStrictEqual(result, { cdnEgressQuota: 1000000n, cacheMissEgressQuota: 500000n }) +``` + +**Parameterized tests**: When testing multiple similar cases, use a single loop with descriptive names instead of multiple `it()` blocks with repeated assertions. One assertion per test, with names describing the specific input condition: + +```typescript +// Bad - multiple assertions in one test +it('should throw error when input is invalid', () => { + assert.throws(() => validate(null), isError) + assert.throws(() => validate('string'), isError) + assert.throws(() => validate(123), isError) +}) + +// Good - parameterized tests with descriptive names +const invalidInputCases: Record = { + 'input is null': null, + 'input is a string': 'string', + 'input is a number': 123, +} +for (const [name, input] of Object.entries(invalidInputCases)) { + it(`should throw error when ${name}`, () => { + assert.throws(() => validate(input), isError) + }) +} +``` + ## Biome Linting (Critical) **NO** `!` operator → use `?.` or explicit checks diff --git a/packages/synapse-core/src/filbeam/stats.ts b/packages/synapse-core/src/filbeam/stats.ts index 548e3ed38..9b6abe572 100644 --- a/packages/synapse-core/src/filbeam/stats.ts +++ b/packages/synapse-core/src/filbeam/stats.ts @@ -47,7 +47,7 @@ function parseBigInt(value: string, fieldName: string): bigint { /** * Validates the response from FilBeam stats API and returns DataSetStats */ -function validateStatsResponse(data: unknown): DataSetStats { +export function validateStatsResponse(data: unknown): DataSetStats { if (typeof data !== 'object' || data === null) { throw new GetDataSetStatsError('Invalid response format', 'Response is not an object') } diff --git a/packages/synapse-core/test/filbeam.test.ts b/packages/synapse-core/test/filbeam.test.ts new file mode 100644 index 000000000..49f3bd56c --- /dev/null +++ b/packages/synapse-core/test/filbeam.test.ts @@ -0,0 +1,76 @@ +import assert from 'assert' +import { getStatsBaseUrl, validateStatsResponse } from '../src/filbeam/stats.ts' + +function isInvalidResponseFormatError(err: unknown): boolean { + if (!(err instanceof Error)) return false + if (err.name !== 'GetDataSetStatsError') return false + if (!err.message.includes('Invalid response format')) return false + return true +} + +describe('FilBeam Stats', () => { + describe('getStatsBaseUrl', () => { + it('should return mainnet URL for chainId 314', () => { + assert.equal(getStatsBaseUrl(314), 'https://stats.filbeam.com') + }) + + it('should return calibration URL for chainId 314159', () => { + assert.equal(getStatsBaseUrl(314159), 'https://calibration.stats.filbeam.com') + }) + + it('should return calibration URL for unknown chain IDs', () => { + assert.equal(getStatsBaseUrl(1), 'https://calibration.stats.filbeam.com') + assert.equal(getStatsBaseUrl(0), 'https://calibration.stats.filbeam.com') + }) + }) + + describe('validateStatsResponse', () => { + it('should return valid DataSetStats for correct input', () => { + const result = validateStatsResponse({ + cdnEgressQuota: '1000000', + cacheMissEgressQuota: '500000', + }) + assert.deepStrictEqual(result, { cdnEgressQuota: 1000000n, cacheMissEgressQuota: 500000n }) + }) + + it('should handle large number strings', () => { + const result = validateStatsResponse({ + cdnEgressQuota: '9999999999999999999999999999', + cacheMissEgressQuota: '1234567890123456789012345678901234567890', + }) + assert.deepStrictEqual(result, { + cdnEgressQuota: 9999999999999999999999999999n, + cacheMissEgressQuota: 1234567890123456789012345678901234567890n, + }) + }) + + it('should handle zero values', () => { + const result = validateStatsResponse({ + cdnEgressQuota: '0', + cacheMissEgressQuota: '0', + }) + assert.deepStrictEqual(result, { cdnEgressQuota: 0n, cacheMissEgressQuota: 0n }) + }) + + const invalidInputCases: Record = { + 'response is null': null, + 'response is a string': 'string', + 'response is an array': [1, 2, 3], + 'response is a number': 123, + 'cdnEgressQuota is missing': { cacheMissEgressQuota: '1000' }, + 'cacheMissEgressQuota is missing': { cdnEgressQuota: '1000' }, + 'cdnEgressQuota is a number': { cdnEgressQuota: 1000, cacheMissEgressQuota: '500' }, + 'cdnEgressQuota is an object': { cdnEgressQuota: { value: 1000 }, cacheMissEgressQuota: '500' }, + 'cdnEgressQuota is a decimal': { cdnEgressQuota: '12.5', cacheMissEgressQuota: '500' }, + 'cdnEgressQuota is non-numeric': { cdnEgressQuota: 'abc', cacheMissEgressQuota: '500' }, + 'cacheMissEgressQuota is a number': { cdnEgressQuota: '1000', cacheMissEgressQuota: 500 }, + 'cacheMissEgressQuota is scientific notation': { cdnEgressQuota: '1000', cacheMissEgressQuota: '1e10' }, + 'cacheMissEgressQuota is empty string': { cdnEgressQuota: '1000', cacheMissEgressQuota: '' }, + } + for (const [name, input] of Object.entries(invalidInputCases)) { + it(`should throw error when ${name}`, () => { + assert.throws(() => validateStatsResponse(input), isInvalidResponseFormatError) + }) + } + }) +}) From 725f20b4f88bf9d2cc8cf1f08b97374054f289a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 14 Jan 2026 12:57:46 +0100 Subject: [PATCH 10/23] fix: add PiB and EiB to formatBytes helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit just in case Signed-off-by: Miroslav Bajtoš --- apps/synapse-playground/src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts index 831bfe7fb..ae8220e52 100644 --- a/apps/synapse-playground/src/lib/utils.ts +++ b/apps/synapse-playground/src/lib/utils.ts @@ -46,7 +46,7 @@ export function formatBytes(bytes: bigint | number): string { const num = typeof bytes === 'bigint' ? Number(bytes) : bytes if (num === 0) return '0 B' - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'] const k = 1024 const i = Math.floor(Math.log(num) / Math.log(k)) const value = num / k ** i From 30952c230f408ec3f30c14a17b484a62ccb954b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 19 Jan 2026 09:48:06 +0100 Subject: [PATCH 11/23] docs: clarify CDN & cache-miss quotas in API docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- packages/synapse-core/src/filbeam/stats.ts | 8 ++++---- packages/synapse-sdk/src/filbeam/service.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/synapse-core/src/filbeam/stats.ts b/packages/synapse-core/src/filbeam/stats.ts index 9b6abe572..3b070db6f 100644 --- a/packages/synapse-core/src/filbeam/stats.ts +++ b/packages/synapse-core/src/filbeam/stats.ts @@ -20,9 +20,9 @@ import { GetDataSetStatsError } from '../errors/filbeam.ts' * represent how many bytes can still be retrieved before needing to add more credits. */ export interface DataSetStats { - /** The remaining CDN egress quota for cache hits (data served directly from FilBeam's cache) in bytes */ + /** The remaining quota for all requests served by FilBeam (both cache-hit and cache-miss) in bytes */ cdnEgressQuota: bigint - /** The remaining egress quota for cache misses (data retrieved from storage providers) in bytes */ + /** The remaining quota for cache-miss requests served by the Storage Provider in bytes */ cacheMissEgressQuota: bigint } @@ -82,8 +82,8 @@ export interface GetDataSetStatsOptions { * track how many bytes can still be retrieved through FilBeam's trusted measurement layer * before needing to add more credits: * - * - **CDN Egress Quota**: Remaining bytes that can be served from FilBeam's cache (fast, direct delivery) - * - **Cache Miss Egress Quota**: Remaining bytes that can be retrieved from storage providers (triggers caching) + * - **CDN Egress Quota**: Remaining bytes for all requests served by FilBeam (both cache-hit and cache-miss) + * - **Cache Miss Egress Quota**: Remaining bytes for cache-miss requests served by the Storage Provider * * @param options - The options for fetching data set stats * @returns A promise that resolves to the data set statistics with remaining quotas as BigInt values diff --git a/packages/synapse-sdk/src/filbeam/service.ts b/packages/synapse-sdk/src/filbeam/service.ts index f10ea19f2..1602e11a5 100644 --- a/packages/synapse-sdk/src/filbeam/service.ts +++ b/packages/synapse-sdk/src/filbeam/service.ts @@ -19,8 +19,8 @@ import { createError } from '../utils/errors.ts' * represent how many bytes can still be retrieved before needing to add more credits. * * @interface DataSetStats - * @property {bigint} cdnEgressQuota - The remaining CDN egress quota for cache hits (data served directly from FilBeam's cache) in bytes - * @property {bigint} cacheMissEgressQuota - The remaining egress quota for cache misses (data retrieved from storage providers) in bytes + * @property {bigint} cdnEgressQuota - The remaining quota for all requests served by FilBeam (both cache-hit and cache-miss) in bytes + * @property {bigint} cacheMissEgressQuota - The remaining quota for cache-miss requests served by the Storage Provider in bytes */ export interface DataSetStats { cdnEgressQuota: bigint @@ -39,7 +39,7 @@ export interface DataSetStats { * // Monitor remaining pay-per-byte quotas * const service = new FilBeamService('mainnet') * const stats = await service.getDataSetStats(12345) - * console.log('Remaining CDN Egress (cache hits):', stats.cdnEgressQuota) + * console.log('Remaining CDN Egress:', stats.cdnEgressQuota) * console.log('Remaining Cache Miss Egress:', stats.cacheMissEgressQuota) * ``` * From 47d0a258548c4a1cd3411c6f389d0462eccf4c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 20 Jan 2026 13:03:27 +0100 Subject: [PATCH 12/23] refactor(playground): extract CdnDetails component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move CDN info (Globe icon + tooltip + egress quota display) into a dedicated CdnDetails component, simplifying data-sets-section.tsx. Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- .../components/warm-storage/cdn-details.tsx | 27 ++++++++++++++ .../warm-storage/data-sets-section.tsx | 36 +++---------------- 2 files changed, 32 insertions(+), 31 deletions(-) create mode 100644 apps/synapse-playground/src/components/warm-storage/cdn-details.tsx diff --git a/apps/synapse-playground/src/components/warm-storage/cdn-details.tsx b/apps/synapse-playground/src/components/warm-storage/cdn-details.tsx new file mode 100644 index 000000000..d0199d82c --- /dev/null +++ b/apps/synapse-playground/src/components/warm-storage/cdn-details.tsx @@ -0,0 +1,27 @@ +import { useEgressQuota } from '@filoz/synapse-react' +import { Globe } from 'lucide-react' +import { formatBytes } from '@/lib/utils.ts' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.tsx' + +export function CdnDetails({ dataSetId }: { dataSetId: bigint }) { + const { data: egressQuota } = useEgressQuota({ dataSetId }) + + return ( + + + + + + +

This data set is using CDN

+
+
+ {egressQuota && ( + <> + Egress remaining: {formatBytes(egressQuota.cdnEgressQuota)} delivery{' · '} + {formatBytes(egressQuota.cacheMissEgressQuota)} cache-miss + + )} +
+ ) +} diff --git a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx index afd2a19c6..23fd67a74 100644 --- a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx +++ b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx @@ -1,9 +1,9 @@ import type { DataSetWithPieces, UseProvidersResult } from '@filoz/synapse-react' -import { useDeletePiece, useEgressQuota } from '@filoz/synapse-react' -import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Globe, Info, Trash } from 'lucide-react' +import { useDeletePiece } from '@filoz/synapse-react' +import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Info, Trash } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' -import { formatBytes, toastError } from '@/lib/utils.ts' +import { toastError } from '@/lib/utils.ts' import { ButtonLoading } from '../custom-ui/button-loading.tsx' import { ExplorerLink } from '../explorer-link.tsx' import { PDPDatasetLink, PDPPieceLink, PDPProviderLink } from '../pdp-link.tsx' @@ -12,23 +12,9 @@ import { Button } from '../ui/button.tsx' import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '../ui/item.tsx' import { Skeleton } from '../ui/skeleton.tsx' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.tsx' +import { CdnDetails } from './cdn-details.tsx' import { CreateDataSetDialog } from './create-data-set.tsx' -function EgressQuotaDisplay({ dataSetId }: { dataSetId: bigint }) { - const { data: egressQuota } = useEgressQuota({ dataSetId }) - - if (!egressQuota) { - return null - } - - return ( - <> - Egress remaining: {formatBytes(egressQuota.cdnEgressQuota)} delivery {' · '} - {formatBytes(egressQuota.cacheMissEgressQuota)} cache-miss - - ) -} - export function DataSetsSection({ dataSets, providers, @@ -124,19 +110,7 @@ export function DataSetsSection({ ))} - {dataSet.cdn && ( - - - - - - -

This data set is using CDN

-
-
- -
- )} + {dataSet.cdn && }

{dataSet.pieces.map((piece) => ( From 730aeea0f310f519ee0f97ea52fa05b05191cc1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 20 Jan 2026 13:07:13 +0100 Subject: [PATCH 13/23] fix(synapse-react): use plural query key for egress quotas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename query key from `synapse-filbeam-egress-quota` to `synapse-filbeam-egress-quotas` for consistency with other query keys that use plural when the response contains multiple values. Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- packages/synapse-react/src/warm-storage/use-egress-quota.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/synapse-react/src/warm-storage/use-egress-quota.ts b/packages/synapse-react/src/warm-storage/use-egress-quota.ts index e278b1f45..72100b495 100644 --- a/packages/synapse-react/src/warm-storage/use-egress-quota.ts +++ b/packages/synapse-react/src/warm-storage/use-egress-quota.ts @@ -39,7 +39,7 @@ export function useEgressQuota(props: UseEgressQuotaProps) { return useQuery({ ...props.query, - queryKey: ['synapse-filbeam-egress-quota', chainId, dataSetId?.toString()], + queryKey: ['synapse-filbeam-egress-quotas', chainId, dataSetId?.toString()], queryFn: dataSetId != null ? () => getDataSetStats({ chainId, dataSetId }) : skipToken, }) } From f2bc85245c41bc2232135f4850e3bf438a245360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 20 Jan 2026 13:54:47 +0100 Subject: [PATCH 14/23] refactor(synapse-sdk): delegate FilBeamService.getDataSetStats to synapse-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify FilBeamService by delegating getDataSetStats to synapse-core's implementation, eliminating code duplication. Add requestGetJson option to synapse-core for testability. Update SynapseError to prioritize explicit details over cause.message. Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- packages/synapse-core/src/errors/base.ts | 8 +- packages/synapse-core/src/errors/filbeam.ts | 4 - packages/synapse-core/src/filbeam/stats.ts | 27 ++-- packages/synapse-sdk/src/filbeam/service.ts | 93 ++--------- .../src/test/filbeam-service.test.ts | 150 +++++++++--------- 5 files changed, 111 insertions(+), 171 deletions(-) diff --git a/packages/synapse-core/src/errors/base.ts b/packages/synapse-core/src/errors/base.ts index 0db7040e8..ceed3c3cc 100644 --- a/packages/synapse-core/src/errors/base.ts +++ b/packages/synapse-core/src/errors/base.ts @@ -22,8 +22,12 @@ export class SynapseError extends Error { shortMessage: string constructor(message: string, options?: SynapseErrorOptions) { - const details = - options?.cause instanceof Error ? options.cause.message : options?.details ? options.details : undefined + // Use explicit details if provided, otherwise fall back to cause.message + const details = options?.details + ? options.details + : options?.cause instanceof Error + ? options.cause.message + : undefined const msg = [ message || 'An error occurred.', diff --git a/packages/synapse-core/src/errors/filbeam.ts b/packages/synapse-core/src/errors/filbeam.ts index 7760b6fc2..68b96b298 100644 --- a/packages/synapse-core/src/errors/filbeam.ts +++ b/packages/synapse-core/src/errors/filbeam.ts @@ -3,10 +3,6 @@ import { isSynapseError, SynapseError } from './base.ts' export class GetDataSetStatsError extends SynapseError { override name: 'GetDataSetStatsError' = 'GetDataSetStatsError' - constructor(message: string, details?: string) { - super(message, { details }) - } - static override is(value: unknown): value is GetDataSetStatsError { return isSynapseError(value) && value.name === 'GetDataSetStatsError' } diff --git a/packages/synapse-core/src/filbeam/stats.ts b/packages/synapse-core/src/filbeam/stats.ts index 3b070db6f..b4b9f7305 100644 --- a/packages/synapse-core/src/filbeam/stats.ts +++ b/packages/synapse-core/src/filbeam/stats.ts @@ -39,7 +39,9 @@ export function getStatsBaseUrl(chainId: number): string { function parseBigInt(value: string, fieldName: string): bigint { // Check if the string is a valid integer format (optional minus sign followed by digits) if (!/^-?\d+$/.test(value)) { - throw new GetDataSetStatsError('Invalid response format', `${fieldName} is not a valid integer: "${value}"`) + throw new GetDataSetStatsError('Invalid response format', { + details: `${fieldName} is not a valid integer: "${value}"`, + }) } return BigInt(value) } @@ -49,17 +51,17 @@ function parseBigInt(value: string, fieldName: string): bigint { */ export function validateStatsResponse(data: unknown): DataSetStats { if (typeof data !== 'object' || data === null) { - throw new GetDataSetStatsError('Invalid response format', 'Response is not an object') + throw new GetDataSetStatsError('Invalid response format', { details: 'Response is not an object' }) } const response = data as Record if (typeof response.cdnEgressQuota !== 'string') { - throw new GetDataSetStatsError('Invalid response format', 'cdnEgressQuota must be a string') + throw new GetDataSetStatsError('Invalid response format', { details: 'cdnEgressQuota must be a string' }) } if (typeof response.cacheMissEgressQuota !== 'string') { - throw new GetDataSetStatsError('Invalid response format', 'cacheMissEgressQuota must be a string') + throw new GetDataSetStatsError('Invalid response format', { details: 'cacheMissEgressQuota must be a string' }) } return { @@ -73,6 +75,8 @@ export interface GetDataSetStatsOptions { chainId: number /** The data set ID to query */ dataSetId: bigint | number | string + /** Optional override for request.json.get (for testing) */ + requestGetJson?: typeof request.json.get } /** @@ -100,22 +104,23 @@ export interface GetDataSetStatsOptions { export async function getDataSetStats(options: GetDataSetStatsOptions): Promise { const baseUrl = getStatsBaseUrl(options.chainId) const url = `${baseUrl}/data-set/${options.dataSetId}` + const requestGetJson = options.requestGetJson ?? request.json.get - const response = await request.json.get(url) + const response = await requestGetJson(url) if (response.error) { if (HttpError.is(response.error)) { const status = response.error.response.status if (status === 404) { - throw new GetDataSetStatsError(`Data set not found: ${options.dataSetId}`) + throw new GetDataSetStatsError(`Data set not found: ${options.dataSetId}`, { cause: response.error }) } const errorText = await response.error.response.text().catch(() => 'Unknown error') - throw new GetDataSetStatsError( - `Failed to fetch data set stats`, - `HTTP ${status} ${response.error.response.statusText}: ${errorText}` - ) + throw new GetDataSetStatsError(`Failed to fetch data set stats`, { + details: `HTTP ${status} ${response.error.response.statusText}: ${errorText}`, + cause: response.error, + }) } - throw response.error + throw new GetDataSetStatsError('Unexpected error', { cause: response.error }) } return validateStatsResponse(response.result) diff --git a/packages/synapse-sdk/src/filbeam/service.ts b/packages/synapse-sdk/src/filbeam/service.ts index 0b8ae7cce..65a61c443 100644 --- a/packages/synapse-sdk/src/filbeam/service.ts +++ b/packages/synapse-sdk/src/filbeam/service.ts @@ -8,24 +8,13 @@ * @see {@link https://docs.filbeam.com | FilBeam Documentation} - Official FilBeam documentation */ +import { getDataSetStats as coreGetDataSetStats, type DataSetStats } from '@filoz/synapse-core/filbeam' +import type { request } from 'iso-web/http' import type { FilecoinNetworkType } from '../types.ts' +import { CHAIN_IDS } from '../utils/constants.ts' import { createError } from '../utils/errors.ts' -/** - * Data set statistics from FilBeam. - * - * These quotas represent the remaining pay-per-byte allocation available for data retrieval - * through FilBeam's trusted measurement layer. The values decrease as data is served and - * represent how many bytes can still be retrieved before needing to add more credits. - * - * @interface DataSetStats - * @property {bigint} cdnEgressQuota - The remaining quota for all requests served by FilBeam (both cache-hit and cache-miss) in bytes - * @property {bigint} cacheMissEgressQuota - The remaining quota for cache-miss requests served by the Storage Provider in bytes - */ -export interface DataSetStats { - cdnEgressQuota: bigint - cacheMissEgressQuota: bigint -} +export type { DataSetStats } /** * Service for interacting with FilBeam infrastructure and APIs. @@ -50,12 +39,12 @@ export interface DataSetStats { */ export class FilBeamService { private readonly _network: FilecoinNetworkType - private readonly _fetch: typeof fetch + private readonly _requestGetJson: typeof request.json.get | undefined - constructor(network: FilecoinNetworkType, fetchImpl: typeof fetch = globalThis.fetch) { + constructor(network: FilecoinNetworkType, requestGetJson?: typeof request.json.get) { this._validateNetworkType(network) this._network = network - this._fetch = fetchImpl + this._requestGetJson = requestGetJson } private _validateNetworkType(network: FilecoinNetworkType) { @@ -68,40 +57,6 @@ export class FilBeamService { ) } - /** - * Get the base stats URL for the current network - */ - private _getStatsBaseUrl(): string { - return this._network === 'mainnet' ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' - } - - /** - * Validates the response from FilBeam stats API - */ - private _validateStatsResponse(data: unknown): { - cdnEgressQuota: string - cacheMissEgressQuota: string - } { - if (typeof data !== 'object' || data === null) { - throw createError('FilBeamService', 'validateStatsResponse', 'Response is not an object') - } - - const response = data as Record - - if (typeof response.cdnEgressQuota !== 'string') { - throw createError('FilBeamService', 'validateStatsResponse', 'cdnEgressQuota must be a string') - } - - if (typeof response.cacheMissEgressQuota !== 'string') { - throw createError('FilBeamService', 'validateStatsResponse', 'cacheMissEgressQuota must be a string') - } - - return { - cdnEgressQuota: response.cdnEgressQuota, - cacheMissEgressQuota: response.cacheMissEgressQuota, - } - } - /** * Retrieves remaining pay-per-byte statistics for a specific data set from FilBeam. * @@ -137,35 +92,11 @@ export class FilBeamService { * ``` */ async getDataSetStats(dataSetId: string | number): Promise { - const baseUrl = this._getStatsBaseUrl() - const url = `${baseUrl}/data-set/${dataSetId}` - - const response = await this._fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, + const chainId = CHAIN_IDS[this._network] + return coreGetDataSetStats({ + chainId, + dataSetId, + requestGetJson: this._requestGetJson, }) - - if (response.status === 404) { - throw createError('FilBeamService', 'getDataSetStats', `Data set not found: ${dataSetId}`) - } - - if (response.status !== 200) { - const errorText = await response.text().catch(() => 'Unknown error') - throw createError( - 'FilBeamService', - 'getDataSetStats', - `HTTP ${response.status} ${response.statusText}: ${errorText}` - ) - } - - const data = await response.json() - const validated = this._validateStatsResponse(data) - - return { - cdnEgressQuota: BigInt(validated.cdnEgressQuota), - cacheMissEgressQuota: BigInt(validated.cacheMissEgressQuota), - } } } diff --git a/packages/synapse-sdk/src/test/filbeam-service.test.ts b/packages/synapse-sdk/src/test/filbeam-service.test.ts index c924bcf22..25dffef87 100644 --- a/packages/synapse-sdk/src/test/filbeam-service.test.ts +++ b/packages/synapse-sdk/src/test/filbeam-service.test.ts @@ -1,7 +1,10 @@ import { expect } from 'chai' +import { HttpError, type request } from 'iso-web/http' import { FilBeamService } from '../filbeam/service.ts' import type { FilecoinNetworkType } from '../types.ts' +type MockRequestGetJson = typeof request.json.get + describe('FilBeamService', () => { describe('network type validation', () => { it('should throw error if network type not mainnet or calibration', () => { @@ -15,24 +18,28 @@ describe('FilBeamService', () => { }) describe('URL construction', () => { - it('should use mainnet URL for mainnet network', () => { - const mockFetch = async (): Promise => { - return {} as Response + it('should use mainnet URL for mainnet network', async () => { + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: { cdnEgressQuota: '100', cacheMissEgressQuota: '50' } } } - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) + await service.getDataSetStats('test') - const baseUrl = (service as any)._getStatsBaseUrl() - expect(baseUrl).to.equal('https://stats.filbeam.com') + expect(calledUrl).to.equal('https://stats.filbeam.com/data-set/test') }) - it('should use calibration URL for calibration network', () => { - const mockFetch = async (): Promise => { - return {} as Response + it('should use calibration URL for calibration network', async () => { + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: { cdnEgressQuota: '100', cacheMissEgressQuota: '50' } } } - const service = new FilBeamService('calibration' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('calibration' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) + await service.getDataSetStats('test') - const baseUrl = (service as any)._getStatsBaseUrl() - expect(baseUrl).to.equal('https://calibration.stats.filbeam.com') + expect(calledUrl).to.equal('https://calibration.stats.filbeam.com/data-set/test') }) }) @@ -43,18 +50,16 @@ describe('FilBeamService', () => { cacheMissEgressQuota: '94243853808', } - const mockFetch = async (input: string | URL | Request): Promise => { - expect(input).to.equal('https://stats.filbeam.com/data-set/test-dataset-id') - return { - status: 200, - statusText: 'OK', - json: async () => mockResponse, - } as Response + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: mockResponse } } - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) const result = await service.getDataSetStats('test-dataset-id') + expect(calledUrl).to.equal('https://stats.filbeam.com/data-set/test-dataset-id') expect(result).to.deep.equal({ cdnEgressQuota: BigInt('217902493044'), cacheMissEgressQuota: BigInt('94243853808'), @@ -67,18 +72,16 @@ describe('FilBeamService', () => { cacheMissEgressQuota: '50000000000', } - const mockFetch = async (input: string | URL | Request): Promise => { - expect(input).to.equal('https://calibration.stats.filbeam.com/data-set/123') - return { - status: 200, - statusText: 'OK', - json: async () => mockResponse, - } as Response + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: mockResponse } } - const service = new FilBeamService('calibration' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('calibration' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) const result = await service.getDataSetStats(123) + expect(calledUrl).to.equal('https://calibration.stats.filbeam.com/data-set/123') expect(result).to.deep.equal({ cdnEgressQuota: BigInt('100000000000'), cacheMissEgressQuota: BigInt('50000000000'), @@ -86,15 +89,15 @@ describe('FilBeamService', () => { }) it('should handle 404 errors gracefully', async () => { - const mockFetch = async (): Promise => { - return { - status: 404, - statusText: 'Not Found', - text: async () => 'Data set not found', - } as Response - } + const mockRequestGetJson = async () => ({ + error: new HttpError({ + response: new Response('Not found', { status: 404, statusText: 'Not Found' }), + request: new Request('https://stats.filbeam.com/data-set/non-existent'), + options: {}, + }), + }) - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) try { await service.getDataSetStats('non-existent') @@ -105,34 +108,29 @@ describe('FilBeamService', () => { }) it('should handle other HTTP errors', async () => { - const mockFetch = async (): Promise => { - return { - status: 500, - statusText: 'Internal Server Error', - text: async () => 'Server error occurred', - } as Response - } + const mockRequestGetJson = async () => ({ + error: new HttpError({ + response: new Response('Server error occurred', { status: 500, statusText: 'Internal Server Error' }), + request: new Request('https://stats.filbeam.com/data-set/test-dataset'), + options: {}, + }), + }) - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) try { await service.getDataSetStats('test-dataset') expect.fail('Should have thrown an error') } catch (error: any) { - expect(error.message).to.include('HTTP 500 Internal Server Error') + expect(error.message).to.include('Failed to fetch data set stats') + expect(error.message).to.include('HTTP 500') } }) it('should validate response is an object', async () => { - const mockFetch = async (): Promise => { - return { - status: 200, - statusText: 'OK', - json: async () => null, - } as Response - } + const mockRequestGetJson = async () => ({ result: null }) - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) try { await service.getDataSetStats('test-dataset') @@ -143,17 +141,11 @@ describe('FilBeamService', () => { }) it('should validate cdnEgressQuota is present', async () => { - const mockFetch = async (): Promise => { - return { - status: 200, - statusText: 'OK', - json: async () => ({ - cacheMissEgressQuota: '12345', - }), - } as Response - } + const mockRequestGetJson = async () => ({ + result: { cacheMissEgressQuota: '12345' }, + }) - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) try { await service.getDataSetStats('test-dataset') @@ -164,17 +156,11 @@ describe('FilBeamService', () => { }) it('should validate cacheMissEgressQuota is present', async () => { - const mockFetch = async (): Promise => { - return { - status: 200, - statusText: 'OK', - json: async () => ({ - cdnEgressQuota: '12345', - }), - } as Response - } + const mockRequestGetJson = async () => ({ + result: { cdnEgressQuota: '12345' }, + }) - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockFetch) + const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) try { await service.getDataSetStats('test-dataset') @@ -183,5 +169,23 @@ describe('FilBeamService', () => { expect(error.message).to.include('cacheMissEgressQuota must be a string') } }) + + it('should reject non-integer quota values', async () => { + const mockRequestGetJson = async () => ({ + result: { + cdnEgressQuota: '12.5', + cacheMissEgressQuota: '100', + }, + }) + + const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) + + try { + await service.getDataSetStats('test-dataset') + expect.fail('Should have thrown an error') + } catch (error: any) { + expect(error.message).to.include('not a valid integer') + } + }) }) }) From 3914329f1d08e3be8e9a2aa0c2b84860e5acc3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 20 Jan 2026 14:01:24 +0100 Subject: [PATCH 15/23] test: move getDataSetStats tests to synapse-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move detailed HTTP error handling and fetch tests from synapse-sdk to synapse-core, keeping only smoke tests in synapse-sdk to verify the delegation works correctly. Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- packages/synapse-core/test/filbeam.test.ts | 107 ++++++++++++- .../src/test/filbeam-service.test.ts | 146 ++---------------- 2 files changed, 122 insertions(+), 131 deletions(-) diff --git a/packages/synapse-core/test/filbeam.test.ts b/packages/synapse-core/test/filbeam.test.ts index 49f3bd56c..0a54f3aca 100644 --- a/packages/synapse-core/test/filbeam.test.ts +++ b/packages/synapse-core/test/filbeam.test.ts @@ -1,5 +1,8 @@ import assert from 'assert' -import { getStatsBaseUrl, validateStatsResponse } from '../src/filbeam/stats.ts' +import { HttpError, type request } from 'iso-web/http' +import { getDataSetStats, getStatsBaseUrl, validateStatsResponse } from '../src/filbeam/stats.ts' + +type MockRequestGetJson = typeof request.json.get function isInvalidResponseFormatError(err: unknown): boolean { if (!(err instanceof Error)) return false @@ -73,4 +76,106 @@ describe('FilBeam Stats', () => { }) } }) + + describe('getDataSetStats', () => { + function isGetDataSetStatsError(err: unknown): boolean { + return err instanceof Error && err.name === 'GetDataSetStatsError' + } + + it('should fetch and return stats for mainnet', async () => { + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: { cdnEgressQuota: '1000', cacheMissEgressQuota: '500' } } + } + + const result = await getDataSetStats({ + chainId: 314, + dataSetId: 'test-dataset', + requestGetJson: mockRequestGetJson as MockRequestGetJson, + }) + + assert.equal(calledUrl, 'https://stats.filbeam.com/data-set/test-dataset') + assert.deepStrictEqual(result, { cdnEgressQuota: 1000n, cacheMissEgressQuota: 500n }) + }) + + it('should fetch and return stats for calibration', async () => { + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: { cdnEgressQuota: '2000', cacheMissEgressQuota: '1000' } } + } + + const result = await getDataSetStats({ + chainId: 314159, + dataSetId: 12345n, + requestGetJson: mockRequestGetJson as MockRequestGetJson, + }) + + assert.equal(calledUrl, 'https://calibration.stats.filbeam.com/data-set/12345') + assert.deepStrictEqual(result, { cdnEgressQuota: 2000n, cacheMissEgressQuota: 1000n }) + }) + + it('should throw error for HTTP 404', async () => { + const mockRequestGetJson = async () => ({ + error: new HttpError({ + response: new Response('Not found', { status: 404, statusText: 'Not Found' }), + request: new Request('https://stats.filbeam.com/data-set/non-existent'), + options: {}, + }), + }) + + try { + await getDataSetStats({ + chainId: 314, + dataSetId: 'non-existent', + requestGetJson: mockRequestGetJson as MockRequestGetJson, + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.ok(isGetDataSetStatsError(error)) + assert.ok((error as Error).message.includes('Data set not found: non-existent')) + } + }) + + it('should throw error for HTTP 500', async () => { + const mockRequestGetJson = async () => ({ + error: new HttpError({ + response: new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }), + request: new Request('https://stats.filbeam.com/data-set/test'), + options: {}, + }), + }) + + try { + await getDataSetStats({ + chainId: 314, + dataSetId: 'test', + requestGetJson: mockRequestGetJson as MockRequestGetJson, + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.ok(isGetDataSetStatsError(error)) + assert.ok((error as Error).message.includes('Failed to fetch data set stats')) + } + }) + + it('should throw error for non-HTTP errors', async () => { + const mockRequestGetJson = async () => ({ + error: new Error('Network error'), + }) + + try { + await getDataSetStats({ + chainId: 314, + dataSetId: 'test', + requestGetJson: mockRequestGetJson as unknown as MockRequestGetJson, + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.ok(isGetDataSetStatsError(error)) + assert.ok((error as Error).message.includes('Unexpected error')) + } + }) + }) }) diff --git a/packages/synapse-sdk/src/test/filbeam-service.test.ts b/packages/synapse-sdk/src/test/filbeam-service.test.ts index 25dffef87..78ce9d6ff 100644 --- a/packages/synapse-sdk/src/test/filbeam-service.test.ts +++ b/packages/synapse-sdk/src/test/filbeam-service.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { HttpError, type request } from 'iso-web/http' +import type { request } from 'iso-web/http' import { FilBeamService } from '../filbeam/service.ts' import type { FilecoinNetworkType } from '../types.ts' @@ -43,149 +43,35 @@ describe('FilBeamService', () => { }) }) + // Detailed tests for HTTP error handling and response validation are in synapse-core. + // These smoke tests verify that FilBeamService delegates to synapse-core correctly. describe('getDataSetStats', () => { - it('should successfully fetch and parse remaining stats for mainnet', async () => { - const mockResponse = { - cdnEgressQuota: '217902493044', - cacheMissEgressQuota: '94243853808', - } - - let calledUrl: string | undefined - const mockRequestGetJson = async (url: unknown) => { - calledUrl = String(url) - return { result: mockResponse } - } - - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) - const result = await service.getDataSetStats('test-dataset-id') - - expect(calledUrl).to.equal('https://stats.filbeam.com/data-set/test-dataset-id') - expect(result).to.deep.equal({ - cdnEgressQuota: BigInt('217902493044'), - cacheMissEgressQuota: BigInt('94243853808'), - }) - }) - - it('should successfully fetch and parse remaining stats for calibration', async () => { - const mockResponse = { - cdnEgressQuota: '100000000000', - cacheMissEgressQuota: '50000000000', - } - - let calledUrl: string | undefined - const mockRequestGetJson = async (url: unknown) => { - calledUrl = String(url) - return { result: mockResponse } - } - - const service = new FilBeamService('calibration' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) - const result = await service.getDataSetStats(123) - - expect(calledUrl).to.equal('https://calibration.stats.filbeam.com/data-set/123') - expect(result).to.deep.equal({ - cdnEgressQuota: BigInt('100000000000'), - cacheMissEgressQuota: BigInt('50000000000'), - }) - }) - - it('should handle 404 errors gracefully', async () => { - const mockRequestGetJson = async () => ({ - error: new HttpError({ - response: new Response('Not found', { status: 404, statusText: 'Not Found' }), - request: new Request('https://stats.filbeam.com/data-set/non-existent'), - options: {}, - }), - }) - - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) - - try { - await service.getDataSetStats('non-existent') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('Data set not found: non-existent') - } - }) - - it('should handle other HTTP errors', async () => { + it('should delegate to synapse-core for mainnet', async () => { const mockRequestGetJson = async () => ({ - error: new HttpError({ - response: new Response('Server error occurred', { status: 500, statusText: 'Internal Server Error' }), - request: new Request('https://stats.filbeam.com/data-set/test-dataset'), - options: {}, - }), + result: { cdnEgressQuota: '1000', cacheMissEgressQuota: '500' }, }) const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) + const result = await service.getDataSetStats('test-dataset') - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('Failed to fetch data set stats') - expect(error.message).to.include('HTTP 500') - } - }) - - it('should validate response is an object', async () => { - const mockRequestGetJson = async () => ({ result: null }) - - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) - - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('Response is not an object') - } - }) - - it('should validate cdnEgressQuota is present', async () => { - const mockRequestGetJson = async () => ({ - result: { cacheMissEgressQuota: '12345' }, + expect(result).to.deep.equal({ + cdnEgressQuota: 1000n, + cacheMissEgressQuota: 500n, }) - - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) - - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('cdnEgressQuota must be a string') - } }) - it('should validate cacheMissEgressQuota is present', async () => { + it('should delegate to synapse-core for calibration', async () => { const mockRequestGetJson = async () => ({ - result: { cdnEgressQuota: '12345' }, + result: { cdnEgressQuota: '2000', cacheMissEgressQuota: '1000' }, }) - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) - - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('cacheMissEgressQuota must be a string') - } - }) + const service = new FilBeamService('calibration' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) + const result = await service.getDataSetStats(123) - it('should reject non-integer quota values', async () => { - const mockRequestGetJson = async () => ({ - result: { - cdnEgressQuota: '12.5', - cacheMissEgressQuota: '100', - }, + expect(result).to.deep.equal({ + cdnEgressQuota: 2000n, + cacheMissEgressQuota: 1000n, }) - - const service = new FilBeamService('mainnet' as FilecoinNetworkType, mockRequestGetJson as MockRequestGetJson) - - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('not a valid integer') - } }) }) }) From 5905842782d4116018e1aba1fbb5dd6c68fc9551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 20 Jan 2026 14:03:10 +0100 Subject: [PATCH 16/23] refactor(synapse-core): organize filbeam stats code top-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move helper functions (getStatsBaseUrl, parseBigInt, validateStatsResponse) after the main getDataSetStats function for better code organization. Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- packages/synapse-core/src/filbeam/stats.ts | 98 ++++++++++++---------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/packages/synapse-core/src/filbeam/stats.ts b/packages/synapse-core/src/filbeam/stats.ts index b4b9f7305..83a6a9ca8 100644 --- a/packages/synapse-core/src/filbeam/stats.ts +++ b/packages/synapse-core/src/filbeam/stats.ts @@ -26,50 +26,6 @@ export interface DataSetStats { cacheMissEgressQuota: bigint } -/** - * Get the base stats URL for a given chain ID - */ -export function getStatsBaseUrl(chainId: number): string { - return chainId === 314 ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' -} - -/** - * Validates that a string can be converted to a valid BigInt - */ -function parseBigInt(value: string, fieldName: string): bigint { - // Check if the string is a valid integer format (optional minus sign followed by digits) - if (!/^-?\d+$/.test(value)) { - throw new GetDataSetStatsError('Invalid response format', { - details: `${fieldName} is not a valid integer: "${value}"`, - }) - } - return BigInt(value) -} - -/** - * Validates the response from FilBeam stats API and returns DataSetStats - */ -export function validateStatsResponse(data: unknown): DataSetStats { - if (typeof data !== 'object' || data === null) { - throw new GetDataSetStatsError('Invalid response format', { details: 'Response is not an object' }) - } - - const response = data as Record - - if (typeof response.cdnEgressQuota !== 'string') { - throw new GetDataSetStatsError('Invalid response format', { details: 'cdnEgressQuota must be a string' }) - } - - if (typeof response.cacheMissEgressQuota !== 'string') { - throw new GetDataSetStatsError('Invalid response format', { details: 'cacheMissEgressQuota must be a string' }) - } - - return { - cdnEgressQuota: parseBigInt(response.cdnEgressQuota, 'cdnEgressQuota'), - cacheMissEgressQuota: parseBigInt(response.cacheMissEgressQuota, 'cacheMissEgressQuota'), - } -} - export interface GetDataSetStatsOptions { /** The chain ID (314 for mainnet, 314159 for calibration) */ chainId: number @@ -112,7 +68,9 @@ export async function getDataSetStats(options: GetDataSetStatsOptions): Promise< if (HttpError.is(response.error)) { const status = response.error.response.status if (status === 404) { - throw new GetDataSetStatsError(`Data set not found: ${options.dataSetId}`, { cause: response.error }) + throw new GetDataSetStatsError(`Data set not found: ${options.dataSetId}`, { + cause: response.error, + }) } const errorText = await response.error.response.text().catch(() => 'Unknown error') throw new GetDataSetStatsError(`Failed to fetch data set stats`, { @@ -125,3 +83,53 @@ export async function getDataSetStats(options: GetDataSetStatsOptions): Promise< return validateStatsResponse(response.result) } + +/** + * Get the base stats URL for a given chain ID + */ +export function getStatsBaseUrl(chainId: number): string { + return chainId === 314 ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' +} + +/** + * Validates that a string can be converted to a valid BigInt + */ +function parseBigInt(value: string, fieldName: string): bigint { + // Check if the string is a valid integer format (optional minus sign followed by digits) + if (!/^-?\d+$/.test(value)) { + throw new GetDataSetStatsError('Invalid response format', { + details: `${fieldName} is not a valid integer: "${value}"`, + }) + } + return BigInt(value) +} + +/** + * Validates the response from FilBeam stats API and returns DataSetStats + */ +export function validateStatsResponse(data: unknown): DataSetStats { + if (typeof data !== 'object' || data === null) { + throw new GetDataSetStatsError('Invalid response format', { + details: 'Response is not an object', + }) + } + + const response = data as Record + + if (typeof response.cdnEgressQuota !== 'string') { + throw new GetDataSetStatsError('Invalid response format', { + details: 'cdnEgressQuota must be a string', + }) + } + + if (typeof response.cacheMissEgressQuota !== 'string') { + throw new GetDataSetStatsError('Invalid response format', { + details: 'cacheMissEgressQuota must be a string', + }) + } + + return { + cdnEgressQuota: parseBigInt(response.cdnEgressQuota, 'cdnEgressQuota'), + cacheMissEgressQuota: parseBigInt(response.cacheMissEgressQuota, 'cacheMissEgressQuota'), + } +} From 6f7f718b91ab6f41bd56b41628299a0be06f4416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 20 Jan 2026 16:29:32 +0100 Subject: [PATCH 17/23] Fix formatBytes to support negative values Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/synapse-playground/src/lib/utils.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts index ae8220e52..83c1c307a 100644 --- a/apps/synapse-playground/src/lib/utils.ts +++ b/apps/synapse-playground/src/lib/utils.ts @@ -46,10 +46,14 @@ export function formatBytes(bytes: bigint | number): string { const num = typeof bytes === 'bigint' ? Number(bytes) : bytes if (num === 0) return '0 B' + const isNegative = num < 0 + const absNum = Math.abs(num) + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'] const k = 1024 - const i = Math.floor(Math.log(num) / Math.log(k)) - const value = num / k ** i + const i = Math.floor(Math.log(absNum) / Math.log(k)) + const value = absNum / k ** i + const formatted = `${value.toFixed(2).replace(/\.?0+$/, '')} ${units[i]}` - return `${value.toFixed(2).replace(/\.?0+$/, '')} ${units[i]}` + return isNegative ? `-${formatted}` : formatted } From 0063a331268c59e015b59baa123b08a8da72800d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 20 Jan 2026 16:37:54 +0100 Subject: [PATCH 18/23] fix: harden the implementation of formatBytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- apps/synapse-playground/src/lib/utils.ts | 28 ++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts index 83c1c307a..3af5d37e2 100644 --- a/apps/synapse-playground/src/lib/utils.ts +++ b/apps/synapse-playground/src/lib/utils.ts @@ -42,18 +42,24 @@ export function toastError(error: Error, id: string, title?: string) { }) } -export function formatBytes(bytes: bigint | number): string { - const num = typeof bytes === 'bigint' ? Number(bytes) : bytes - if (num === 0) return '0 B' +const UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'] - const isNegative = num < 0 - const absNum = Math.abs(num) +export function formatBytes(bytes: bigint | number): string { + const isNegative = bytes < 0 + let num = isNegative ? -BigInt(bytes) : BigInt(bytes) + if (num === 0n) return '0 B' - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'] - const k = 1024 - const i = Math.floor(Math.log(absNum) / Math.log(k)) - const value = absNum / k ** i - const formatted = `${value.toFixed(2).replace(/\.?0+$/, '')} ${units[i]}` + const kBig = 1024n + let i = 0 + while (num >= kBig && i < UNITS.length - 1) { + num /= kBig + i++ + } - return isNegative ? `-${formatted}` : formatted + const sign = isNegative ? '-' : '' + const value = Number(num) + .toFixed(2) + .replace(/\.?0+$/, '') + const unit = UNITS[i] + return `${sign}${value} ${unit}` } From 1bd4f2c5ed71b4ddff4554edfc43adcb41dc5ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 21 Jan 2026 11:28:43 +0100 Subject: [PATCH 19/23] Update AGENTS.md Co-authored-by: Rod Vagg --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6aa4dfa75..1c6257d61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ packages/synapse-sdk/src/ ## Monorepo Package Structure -**synapse-core** (`packages/synapse-core/`): Low-level utilities, types, chain definitions, and blockchain interactions using viem. Shared foundation for both synapse-react and synapse-sdk. +**synapse-core** (`packages/synapse-core/` `@filoz/synapse-core`): Stateless, low-level library of pure functions. Includes utilities, types, chain definitions, and blockchain interactions using viem. Shared foundation for both synapse-react and synapse-sdk. **synapse-react** (`packages/synapse-react/`): React hooks wrapping synapse-core for React apps. Uses wagmi + @tanstack/react-query. Does NOT depend on synapse-sdk. From 53c49e5e9bb5a4d42da1ac2304fc65c6d0af17d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 21 Jan 2026 11:28:58 +0100 Subject: [PATCH 20/23] Update AGENTS.md Co-authored-by: Rod Vagg --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1c6257d61..fd52f77c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ packages/synapse-sdk/src/ **synapse-react** (`packages/synapse-react/`): React hooks wrapping synapse-core for React apps. Uses wagmi + @tanstack/react-query. Does NOT depend on synapse-sdk. -**synapse-sdk** (`packages/synapse-sdk/`): High-level SDK using ethers.js. Includes services like FilBeamService. For Node.js and browser script usage. +**synapse-sdk** (`packages/synapse-sdk/` `@filoz/synapse-sdk`): Stateful service classes. DX-focused golden path (_don't make me care about your junk_). **synapse-playground** (`apps/synapse-playground/`): Vite-based React demo app using synapse-react hooks. Dev server runs at localhost:5173. From 501b661cf3f406182df4bab02bd8e4b52ef7f315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 4 Feb 2026 11:00:45 +0100 Subject: [PATCH 21/23] refactor(core): move FilBeam stats URL to chain config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move hardcoded stats URL from getStatsBaseUrl function into chain configuration (chain.filbeam.statsBaseUrl). Change getDataSetStats to accept chain object instead of chainId for consistency with other chain-aware functions. Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- packages/synapse-core/src/chains.ts | 3 ++ packages/synapse-core/src/filbeam/stats.ts | 23 ++++++++------- packages/synapse-core/test/filbeam.test.ts | 28 +++++-------------- .../src/warm-storage/use-egress-quota.ts | 4 ++- packages/synapse-sdk/src/filbeam/service.ts | 2 +- 5 files changed, 25 insertions(+), 35 deletions(-) diff --git a/packages/synapse-core/src/chains.ts b/packages/synapse-core/src/chains.ts index a52fd6c85..574c468e2 100644 --- a/packages/synapse-core/src/chains.ts +++ b/packages/synapse-core/src/chains.ts @@ -61,6 +61,7 @@ export interface Chain extends ViemChain { } filbeam: { retrievalDomain: string + statsBaseUrl: string } | null } @@ -142,6 +143,7 @@ export const mainnet: Chain = { }, filbeam: { retrievalDomain: 'filbeam.io', + statsBaseUrl: 'https://stats.filbeam.com', }, /** * Filecoin Mainnet genesis: August 24, 2020 22:00:00 UTC @@ -227,6 +229,7 @@ export const calibration: Chain = { }, filbeam: { retrievalDomain: 'calibration.filbeam.io', + statsBaseUrl: 'https://calibration.stats.filbeam.com', }, testnet: true, /** diff --git a/packages/synapse-core/src/filbeam/stats.ts b/packages/synapse-core/src/filbeam/stats.ts index 83a6a9ca8..2764e0c57 100644 --- a/packages/synapse-core/src/filbeam/stats.ts +++ b/packages/synapse-core/src/filbeam/stats.ts @@ -10,6 +10,7 @@ */ import { HttpError, request } from 'iso-web/http' +import type { Chain } from '../chains.ts' import { GetDataSetStatsError } from '../errors/filbeam.ts' /** @@ -27,8 +28,8 @@ export interface DataSetStats { } export interface GetDataSetStatsOptions { - /** The chain ID (314 for mainnet, 314159 for calibration) */ - chainId: number + /** The chain configuration containing FilBeam stats URL */ + chain: Chain /** The data set ID to query */ dataSetId: bigint | number | string /** Optional override for request.json.get (for testing) */ @@ -48,17 +49,22 @@ export interface GetDataSetStatsOptions { * @param options - The options for fetching data set stats * @returns A promise that resolves to the data set statistics with remaining quotas as BigInt values * - * @throws {GetDataSetStatsError} If the data set is not found, the API returns an invalid response, or network errors occur + * @throws {GetDataSetStatsError} If the chain doesn't support FilBeam, data set is not found, the API returns an invalid response, or network errors occur * * @example * ```typescript - * const stats = await getDataSetStats({ chainId: 314, dataSetId: 12345n }) + * import { mainnet } from '@filoz/synapse-core/chains' + * + * const stats = await getDataSetStats({ chain: mainnet, dataSetId: 12345n }) * console.log(`Remaining CDN Egress: ${stats.cdnEgressQuota} bytes`) * console.log(`Remaining Cache Miss: ${stats.cacheMissEgressQuota} bytes`) * ``` */ export async function getDataSetStats(options: GetDataSetStatsOptions): Promise { - const baseUrl = getStatsBaseUrl(options.chainId) + if (!options.chain.filbeam) { + throw new GetDataSetStatsError(`Chain ${options.chain.id} (${options.chain.name}) does not support FilBeam`) + } + const baseUrl = options.chain.filbeam.statsBaseUrl const url = `${baseUrl}/data-set/${options.dataSetId}` const requestGetJson = options.requestGetJson ?? request.json.get @@ -84,13 +90,6 @@ export async function getDataSetStats(options: GetDataSetStatsOptions): Promise< return validateStatsResponse(response.result) } -/** - * Get the base stats URL for a given chain ID - */ -export function getStatsBaseUrl(chainId: number): string { - return chainId === 314 ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' -} - /** * Validates that a string can be converted to a valid BigInt */ diff --git a/packages/synapse-core/test/filbeam.test.ts b/packages/synapse-core/test/filbeam.test.ts index 0a54f3aca..e3252d97f 100644 --- a/packages/synapse-core/test/filbeam.test.ts +++ b/packages/synapse-core/test/filbeam.test.ts @@ -1,6 +1,7 @@ import assert from 'assert' import { HttpError, type request } from 'iso-web/http' -import { getDataSetStats, getStatsBaseUrl, validateStatsResponse } from '../src/filbeam/stats.ts' +import { calibration, mainnet } from '../src/chains.ts' +import { getDataSetStats, validateStatsResponse } from '../src/filbeam/stats.ts' type MockRequestGetJson = typeof request.json.get @@ -12,21 +13,6 @@ function isInvalidResponseFormatError(err: unknown): boolean { } describe('FilBeam Stats', () => { - describe('getStatsBaseUrl', () => { - it('should return mainnet URL for chainId 314', () => { - assert.equal(getStatsBaseUrl(314), 'https://stats.filbeam.com') - }) - - it('should return calibration URL for chainId 314159', () => { - assert.equal(getStatsBaseUrl(314159), 'https://calibration.stats.filbeam.com') - }) - - it('should return calibration URL for unknown chain IDs', () => { - assert.equal(getStatsBaseUrl(1), 'https://calibration.stats.filbeam.com') - assert.equal(getStatsBaseUrl(0), 'https://calibration.stats.filbeam.com') - }) - }) - describe('validateStatsResponse', () => { it('should return valid DataSetStats for correct input', () => { const result = validateStatsResponse({ @@ -90,7 +76,7 @@ describe('FilBeam Stats', () => { } const result = await getDataSetStats({ - chainId: 314, + chain: mainnet, dataSetId: 'test-dataset', requestGetJson: mockRequestGetJson as MockRequestGetJson, }) @@ -107,7 +93,7 @@ describe('FilBeam Stats', () => { } const result = await getDataSetStats({ - chainId: 314159, + chain: calibration, dataSetId: 12345n, requestGetJson: mockRequestGetJson as MockRequestGetJson, }) @@ -127,7 +113,7 @@ describe('FilBeam Stats', () => { try { await getDataSetStats({ - chainId: 314, + chain: mainnet, dataSetId: 'non-existent', requestGetJson: mockRequestGetJson as MockRequestGetJson, }) @@ -149,7 +135,7 @@ describe('FilBeam Stats', () => { try { await getDataSetStats({ - chainId: 314, + chain: mainnet, dataSetId: 'test', requestGetJson: mockRequestGetJson as MockRequestGetJson, }) @@ -167,7 +153,7 @@ describe('FilBeam Stats', () => { try { await getDataSetStats({ - chainId: 314, + chain: mainnet, dataSetId: 'test', requestGetJson: mockRequestGetJson as unknown as MockRequestGetJson, }) diff --git a/packages/synapse-react/src/warm-storage/use-egress-quota.ts b/packages/synapse-react/src/warm-storage/use-egress-quota.ts index 72100b495..dbb8e9ad2 100644 --- a/packages/synapse-react/src/warm-storage/use-egress-quota.ts +++ b/packages/synapse-react/src/warm-storage/use-egress-quota.ts @@ -1,3 +1,4 @@ +import { getChain } from '@filoz/synapse-core/chains' import { type DataSetStats, getDataSetStats } from '@filoz/synapse-core/filbeam' import { skipToken, type UseQueryOptions, useQuery } from '@tanstack/react-query' import { useChainId } from 'wagmi' @@ -35,11 +36,12 @@ export type UseEgressQuotaResult = DataSetStats */ export function useEgressQuota(props: UseEgressQuotaProps) { const chainId = useChainId() + const chain = getChain(chainId) const dataSetId = props.dataSetId return useQuery({ ...props.query, queryKey: ['synapse-filbeam-egress-quotas', chainId, dataSetId?.toString()], - queryFn: dataSetId != null ? () => getDataSetStats({ chainId, dataSetId }) : skipToken, + queryFn: dataSetId != null ? () => getDataSetStats({ chain, dataSetId }) : skipToken, }) } diff --git a/packages/synapse-sdk/src/filbeam/service.ts b/packages/synapse-sdk/src/filbeam/service.ts index 02f37191a..a8fe0df29 100644 --- a/packages/synapse-sdk/src/filbeam/service.ts +++ b/packages/synapse-sdk/src/filbeam/service.ts @@ -80,7 +80,7 @@ export class FilBeamService { */ async getDataSetStats(dataSetId: string | number): Promise { return coreGetDataSetStats({ - chainId: this._chain.id, + chain: this._chain, dataSetId, requestGetJson: this._requestGetJson, }) From a7f6bb4e1391c8963bf0e88ed6f50e59e6bf0623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 4 Feb 2026 11:11:01 +0100 Subject: [PATCH 22/23] Update apps/synapse-playground/src/lib/utils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/synapse-playground/src/lib/utils.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts index 74e0916e6..cb47e1616 100644 --- a/apps/synapse-playground/src/lib/utils.ts +++ b/apps/synapse-playground/src/lib/utils.ts @@ -44,6 +44,19 @@ export function toastError(error: Error, id: string, title?: string) { const UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'] +/** + * Formats a byte value into a human-readable string using binary units (KiB, MiB, etc.). + * Supports both `number` and `bigint` inputs and preserves the sign for negative values. + * + * @param bytes - The number of bytes to format. Can be a `number` or `bigint`, positive or negative. + * @returns A human-readable string representation of the byte value (e.g., `"1.23 MiB"`). + * @example + * ```ts twoslash + * import { formatBytes } from 'filsnap/utils' + * formatBytes(1024) // "1 KiB" + * formatBytes(1048576) // "1 MiB" + * ``` + */ export function formatBytes(bytes: bigint | number): string { const isNegative = bytes < 0 let num = isNegative ? -BigInt(bytes) : BigInt(bytes) From 8071a7041d199faea37959f4ddca81f64a2056f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 4 Feb 2026 11:18:08 +0100 Subject: [PATCH 23/23] test(core): add test for FilBeam unsupported chain error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test coverage for getDataSetStats when called with a chain that doesn't support FilBeam (chain.filbeam is null). Co-Authored-By: Claude Code Signed-off-by: Miroslav Bajtoš --- packages/synapse-core/test/filbeam.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/synapse-core/test/filbeam.test.ts b/packages/synapse-core/test/filbeam.test.ts index e3252d97f..ce0bddb68 100644 --- a/packages/synapse-core/test/filbeam.test.ts +++ b/packages/synapse-core/test/filbeam.test.ts @@ -1,6 +1,6 @@ import assert from 'assert' import { HttpError, type request } from 'iso-web/http' -import { calibration, mainnet } from '../src/chains.ts' +import { calibration, devnet, mainnet } from '../src/chains.ts' import { getDataSetStats, validateStatsResponse } from '../src/filbeam/stats.ts' type MockRequestGetJson = typeof request.json.get @@ -163,5 +163,19 @@ describe('FilBeam Stats', () => { assert.ok((error as Error).message.includes('Unexpected error')) } }) + + it('should throw error when chain does not support FilBeam', async () => { + try { + await getDataSetStats({ + chain: devnet, + dataSetId: '12345', + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.ok(isGetDataSetStatsError(error)) + assert.ok((error as Error).message.includes('does not support FilBeam')) + assert.ok((error as Error).message.includes('31415926')) + } + }) }) })