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'))
+ }
+ })
})
})