Skip to content
Open
53 changes: 52 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand All @@ -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<string, unknown> = {
'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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we find a better name for this hook? E.g. useFetchEgressQuotas?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is a fetch egress quota? I don't think I've heard the term before

Copy link
Contributor Author

@bajtos bajtos Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposed name is a combination of the "use" prefix, followed by a verb ("to fetch"), followed by the object ("egress quota").

Similarly to how `useDeletePiece" is a combination of the "use" prefix, the verb "to delete", and the object "piece".

How about useGetEgressQuotas - is that easier to understand?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see! I thought this was about a type of egress quota. I don't think we need a verb here, like there's also useState, and not useSetAndRetrieveState. I'd say useDeletePiece is a bit odd, but there the verb makes sense, since it's only one particular piece action.

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'
Expand All @@ -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,
Expand Down Expand Up @@ -110,14 +125,17 @@ export function DataSetsSection({
</TooltipContent>
</Tooltip>
{dataSet.cdn && (
<Tooltip>
<TooltipTrigger>
<Globe className="w-4" />
</TooltipTrigger>
<TooltipContent>
<p>This data set is using CDN</p>
</TooltipContent>
</Tooltip>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Tooltip>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would make a custom tooltip component wrapping the globe icon with all the cdn info, using a hook to fetch the data.

check the other hooks that use tanstack query to wrap fetchDataSetEgressQuota

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make a custom tooltip component wrapping the globe icon with all the cdn info, using a hook to fetch the data.

Since we are fetching isCDN flag in a different network request than the egress quotas info, I feel it's better to keep the current globe & tooltip as-is, and let the new component handle only the part about egress quotas.

That's what I implemented in the new version.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OTOH, I can see how it would make sense to move the entire <span> into a new component, growing EgressQuotaDisplay to something like CdnDetails.

{dataSet.cdn && <CdnDetails dataSetId={dataSet.dataSetId}/>}

Is that what you had in mind?

Can you suggest a better name for this new component?

<TooltipTrigger>
<Globe className="w-4" />
</TooltipTrigger>
<TooltipContent>
<p>This data set is using CDN</p>
</TooltipContent>
</Tooltip>
<EgressQuotaDisplay dataSetId={dataSet.dataSetId} />
</span>
)}
</p>

Expand Down
12 changes: 12 additions & 0 deletions apps/synapse-playground/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`
}
7 changes: 7 additions & 0 deletions packages/synapse-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -120,6 +124,9 @@
"errors": [
"./dist/src/errors/index"
],
"filbeam": [
"./dist/src/filbeam/index"
],
"piece": [
"./dist/src/piece"
],
Expand Down
13 changes: 13 additions & 0 deletions packages/synapse-core/src/errors/filbeam.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
1 change: 1 addition & 0 deletions packages/synapse-core/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
11 changes: 11 additions & 0 deletions packages/synapse-core/src/filbeam/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* FilBeam API
*
* @example
* ```ts
* import * as FilBeam from '@filoz/synapse-core/filbeam'
* ```
*
* @module filbeam
*/
export * from './stats.ts'
122 changes: 122 additions & 0 deletions packages/synapse-core/src/filbeam/stats.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>

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<DataSetStats> {
const baseUrl = getStatsBaseUrl(options.chainId)
const url = `${baseUrl}/data-set/${options.dataSetId}`

const response = await request.json.get<unknown>(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)
}
76 changes: 76 additions & 0 deletions packages/synapse-core/test/filbeam.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
'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)
})
}
})
})
Loading