diff --git a/AGENTS.md b/AGENTS.md
index da06fbf6..5ac9166f 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**:
@@ -53,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/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx
index 8354bb2f..afd2a19c 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 { 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'
-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'
@@ -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,
@@ -110,14 +125,17 @@ export function DataSetsSection({
{dataSet.cdn && (
-
-
-
-
-
- This data set is using CDN
-
-
+
+
+
+
+
+
+ This data set is using CDN
+
+
+
+
)}
diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts
index e9b1f1b1..ae8220e5 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', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB']
+ const k = 1024
+ const i = Math.floor(Math.log(num) / Math.log(k))
+ const value = num / k ** i
+
+ return `${value.toFixed(2).replace(/\.?0+$/, '')} ${units[i]}`
+}
diff --git a/packages/synapse-core/package.json b/packages/synapse-core/package.json
index 4db809c7..993f1558 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 00000000..7760b6fc
--- /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 0fc26f89..8c723915 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 00000000..9e2963c4
--- /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 00000000..9b6abe57
--- /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
+ */
+export 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-core/test/filbeam.test.ts b/packages/synapse-core/test/filbeam.test.ts
new file mode 100644
index 00000000..49f3bd56
--- /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)
+ })
+ }
+ })
+})
diff --git a/packages/synapse-react/src/warm-storage/index.ts b/packages/synapse-react/src/warm-storage/index.ts
index afe8eab1..4dfcae2c 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-egress-quota.ts b/packages/synapse-react/src/warm-storage/use-egress-quota.ts
new file mode 100644
index 00000000..e278b1f4
--- /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 3b7853c4..f10ea19f 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.