diff --git a/AGENTS.md b/AGENTS.md
index cd81fe173..28cba219d 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/` `@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.
+
+**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.
+
## 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/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 8354bb2f3..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,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, Trash } from 'lucide-react'
+import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Info, Trash } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { toastError } from '@/lib/utils.ts'
@@ -12,6 +12,7 @@ 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'
export function DataSetsSection({
@@ -109,16 +110,7 @@ export function DataSetsSection({
))}
- {dataSet.cdn && (
-
-
-
-
-
- This data set is using CDN
-
-
- )}
+ {dataSet.cdn && }
{dataSet.pieces.map((piece) => (
diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts
index 30c807742..cb47e1616 100644
--- a/apps/synapse-playground/src/lib/utils.ts
+++ b/apps/synapse-playground/src/lib/utils.ts
@@ -41,3 +41,38 @@ export function toastError(error: Error, id: string, title?: string) {
id,
})
}
+
+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)
+ if (num === 0n) return '0 B'
+
+ const kBig = 1024n
+ let i = 0
+ while (num >= kBig && i < UNITS.length - 1) {
+ num /= kBig
+ i++
+ }
+
+ const sign = isNegative ? '-' : ''
+ const value = Number(num)
+ .toFixed(2)
+ .replace(/\.?0+$/, '')
+ const unit = UNITS[i]
+ return `${sign}${value} ${unit}`
+}
diff --git a/packages/synapse-core/package.json b/packages/synapse-core/package.json
index 942ba57ca..a85d6e597 100644
--- a/packages/synapse-core/package.json
+++ b/packages/synapse-core/package.json
@@ -81,6 +81,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"
@@ -136,6 +140,9 @@
"errors": [
"./dist/src/errors/index"
],
+ "filbeam": [
+ "./dist/src/filbeam/index"
+ ],
"piece": [
"./dist/src/piece"
],
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/errors/base.ts b/packages/synapse-core/src/errors/base.ts
index b4f512195..869ffd85e 100644
--- a/packages/synapse-core/src/errors/base.ts
+++ b/packages/synapse-core/src/errors/base.ts
@@ -24,6 +24,7 @@ export class SynapseError extends Error {
shortMessage: string
constructor(message: string, options?: SynapseErrorOptions) {
+ // Use explicit details if provided, otherwise fall back to cause.message
const details = options?.details
? options.details
: options?.cause instanceof Error
diff --git a/packages/synapse-core/src/errors/filbeam.ts b/packages/synapse-core/src/errors/filbeam.ts
new file mode 100644
index 000000000..68b96b298
--- /dev/null
+++ b/packages/synapse-core/src/errors/filbeam.ts
@@ -0,0 +1,9 @@
+import { isSynapseError, SynapseError } from './base.ts'
+
+export class GetDataSetStatsError extends SynapseError {
+ override name: 'GetDataSetStatsError' = 'GetDataSetStatsError'
+
+ 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..2764e0c57
--- /dev/null
+++ b/packages/synapse-core/src/filbeam/stats.ts
@@ -0,0 +1,134 @@
+/**
+ * FilBeam Stats API
+ *
+ * @example
+ * ```ts
+ * import { getDataSetStats } from '@filoz/synapse-core/filbeam'
+ * ```
+ *
+ * @module filbeam
+ */
+
+import { HttpError, request } from 'iso-web/http'
+import type { Chain } from '../chains.ts'
+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 quota for all requests served by FilBeam (both cache-hit and cache-miss) in bytes */
+ cdnEgressQuota: bigint
+ /** The remaining quota for cache-miss requests served by the Storage Provider in bytes */
+ cacheMissEgressQuota: bigint
+}
+
+export interface GetDataSetStatsOptions {
+ /** 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) */
+ requestGetJson?: typeof request.json.get
+}
+
+/**
+ * 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 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
+ *
+ * @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
+ * 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 {
+ 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
+
+ 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}`, {
+ cause: response.error,
+ })
+ }
+ const errorText = await response.error.response.text().catch(() => 'Unknown error')
+ throw new GetDataSetStatsError(`Failed to fetch data set stats`, {
+ details: `HTTP ${status} ${response.error.response.statusText}: ${errorText}`,
+ cause: response.error,
+ })
+ }
+ throw new GetDataSetStatsError('Unexpected error', { cause: response.error })
+ }
+
+ return validateStatsResponse(response.result)
+}
+
+/**
+ * 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'),
+ }
+}
diff --git a/packages/synapse-core/test/filbeam.test.ts b/packages/synapse-core/test/filbeam.test.ts
new file mode 100644
index 000000000..ce0bddb68
--- /dev/null
+++ b/packages/synapse-core/test/filbeam.test.ts
@@ -0,0 +1,181 @@
+import assert from 'assert'
+import { HttpError, type request } from 'iso-web/http'
+import { calibration, devnet, mainnet } from '../src/chains.ts'
+import { getDataSetStats, validateStatsResponse } from '../src/filbeam/stats.ts'
+
+type MockRequestGetJson = typeof request.json.get
+
+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('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)
+ })
+ }
+ })
+
+ 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({
+ chain: mainnet,
+ 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({
+ chain: calibration,
+ 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({
+ chain: mainnet,
+ 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({
+ chain: mainnet,
+ 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({
+ chain: mainnet,
+ 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'))
+ }
+ })
+
+ 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'))
+ }
+ })
+ })
+})
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-egress-quota.ts b/packages/synapse-react/src/warm-storage/use-egress-quota.ts
new file mode 100644
index 000000000..dbb8e9ad2
--- /dev/null
+++ b/packages/synapse-react/src/warm-storage/use-egress-quota.ts
@@ -0,0 +1,47 @@
+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'
+
+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 chain = getChain(chainId)
+ const dataSetId = props.dataSetId
+
+ return useQuery({
+ ...props.query,
+ queryKey: ['synapse-filbeam-egress-quotas', chainId, dataSetId?.toString()],
+ 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 9f195dcf3..a8fe0df29 100644
--- a/packages/synapse-sdk/src/filbeam/service.ts
+++ b/packages/synapse-sdk/src/filbeam/service.ts
@@ -9,23 +9,10 @@
*/
import { asChain, type Chain } from '@filoz/synapse-core/chains'
-import { createError } from '../utils/errors.ts'
+import { getDataSetStats as coreGetDataSetStats, type DataSetStats } from '@filoz/synapse-core/filbeam'
+import type { request } from 'iso-web/http'
-/**
- * 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 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
- */
-export interface DataSetStats {
- cdnEgressQuota: bigint
- cacheMissEgressQuota: bigint
-}
+export type { DataSetStats }
/**
* Service for interacting with FilBeam infrastructure and APIs.
@@ -37,9 +24,9 @@ export interface DataSetStats {
* const stats = await synapse.filbeam.getDataSetStats(12345)
*
* // Monitor remaining pay-per-byte quotas
- * const service = new FilBeamService('mainnet')
+ * 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)
* ```
*
@@ -50,42 +37,11 @@ export interface DataSetStats {
*/
export class FilBeamService {
private readonly _chain: Chain
- private readonly _fetch: typeof fetch
+ private readonly _requestGetJson: typeof request.json.get | undefined
- constructor(chain: Chain, fetchImpl: typeof fetch = globalThis.fetch) {
+ constructor(chain: Chain, requestGetJson?: typeof request.json.get) {
this._chain = asChain(chain)
- this._fetch = fetchImpl
- }
-
- /**
- * Get the base stats URL for the current network
- */
- private _getStatsBaseUrl(): string {
- return this._chain.testnet ? 'https://calibration.stats.filbeam.com' : 'https://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,
- }
+ this._requestGetJson = requestGetJson
}
/**
@@ -95,8 +51,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.
@@ -123,35 +79,10 @@ 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',
- },
+ return coreGetDataSetStats({
+ chain: this._chain,
+ 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 44299a2db..4d9389be9 100644
--- a/packages/synapse-sdk/src/test/filbeam-service.test.ts
+++ b/packages/synapse-sdk/src/test/filbeam-service.test.ts
@@ -1,176 +1,66 @@
import { calibration, mainnet } from '@filoz/synapse-core/chains'
import { expect } from 'chai'
+import type { request } from 'iso-web/http'
import { FilBeamService } from '../filbeam/service.ts'
+type MockRequestGetJson = typeof request.json.get
+
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, mockFetch)
+ const service = new FilBeamService(mainnet, 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, mockFetch)
+ const service = new FilBeamService(calibration, 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')
})
})
+ // 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',
- }
-
- 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
- }
+ it('should delegate to synapse-core for mainnet', async () => {
+ const mockRequestGetJson = async () => ({
+ result: { cdnEgressQuota: '1000', cacheMissEgressQuota: '500' },
+ })
- const service = new FilBeamService(mainnet, mockFetch)
- const result = await service.getDataSetStats('test-dataset-id')
+ const service = new FilBeamService(mainnet, mockRequestGetJson as MockRequestGetJson)
+ const result = await service.getDataSetStats('test-dataset')
expect(result).to.deep.equal({
- cdnEgressQuota: BigInt('217902493044'),
- cacheMissEgressQuota: BigInt('94243853808'),
+ cdnEgressQuota: 1000n,
+ cacheMissEgressQuota: 500n,
})
})
- it('should successfully fetch and parse remaining stats for calibration', async () => {
- const mockResponse = {
- cdnEgressQuota: '100000000000',
- 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
- }
+ it('should delegate to synapse-core for calibration', async () => {
+ const mockRequestGetJson = async () => ({
+ result: { cdnEgressQuota: '2000', cacheMissEgressQuota: '1000' },
+ })
- const service = new FilBeamService(calibration, mockFetch)
+ const service = new FilBeamService(calibration, mockRequestGetJson as MockRequestGetJson)
const result = await service.getDataSetStats(123)
expect(result).to.deep.equal({
- cdnEgressQuota: BigInt('100000000000'),
- cacheMissEgressQuota: BigInt('50000000000'),
+ cdnEgressQuota: 2000n,
+ cacheMissEgressQuota: 1000n,
})
})
-
- 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 service = new FilBeamService(mainnet, mockFetch)
-
- 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 () => {
- const mockFetch = async (): Promise => {
- return {
- status: 500,
- statusText: 'Internal Server Error',
- text: async () => 'Server error occurred',
- } as Response
- }
-
- const service = new FilBeamService(mainnet, mockFetch)
-
- 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')
- }
- })
-
- it('should validate response is an object', async () => {
- const mockFetch = async (): Promise => {
- return {
- status: 200,
- statusText: 'OK',
- json: async () => null,
- } as Response
- }
-
- const service = new FilBeamService(mainnet, mockFetch)
-
- 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 mockFetch = async (): Promise => {
- return {
- status: 200,
- statusText: 'OK',
- json: async () => ({
- cacheMissEgressQuota: '12345',
- }),
- } as Response
- }
-
- const service = new FilBeamService(mainnet, mockFetch)
-
- 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 () => {
- const mockFetch = async (): Promise => {
- return {
- status: 200,
- statusText: 'OK',
- json: async () => ({
- cdnEgressQuota: '12345',
- }),
- } as Response
- }
-
- const service = new FilBeamService(mainnet, mockFetch)
-
- 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')
- }
- })
})
})