Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fd491d0
feat: show remaining CDN & cache-miss egress
bajtos Jan 7, 2026
9aa16b7
chore: document synapse-react in AGENTS.md
bajtos Jan 7, 2026
2132a05
fixup! cache-miss icon RefreshCW -> FolderSync
bajtos Jan 7, 2026
f029bc4
fixup! npm run lint:fix
bajtos Jan 7, 2026
b1f0f04
fixup! code cleanup + better error logging
bajtos Jan 7, 2026
10c3811
fixup! forgotten err
bajtos Jan 7, 2026
c0bbd0e
Merge branch 'master' into feat-egress-allowance
bajtos Jan 7, 2026
ac134f7
fix(playground): improve egress quota display in Data Sets UI
bajtos Jan 13, 2026
ad49466
refactor: move FilBeam egress quota logic to synapse-core
bajtos Jan 14, 2026
164bc70
test(synapse-core): add tests for FilBeam stats module
bajtos Jan 14, 2026
725f20b
fix: add PiB and EiB to formatBytes helper
bajtos Jan 14, 2026
30952c2
docs: clarify CDN & cache-miss quotas in API docs
bajtos Jan 19, 2026
abd0d39
Merge branch 'master' into feat-egress-allowance
bajtos Jan 20, 2026
47d0a25
refactor(playground): extract CdnDetails component
bajtos Jan 20, 2026
730aeea
fix(synapse-react): use plural query key for egress quotas
bajtos Jan 20, 2026
f2bc852
refactor(synapse-sdk): delegate FilBeamService.getDataSetStats to syn…
bajtos Jan 20, 2026
3914329
test: move getDataSetStats tests to synapse-core
bajtos Jan 20, 2026
5905842
refactor(synapse-core): organize filbeam stats code top-down
bajtos Jan 20, 2026
6f7f718
Fix formatBytes to support negative values
bajtos Jan 20, 2026
0063a33
fix: harden the implementation of formatBytes
bajtos Jan 20, 2026
1bd4f2c
Update AGENTS.md
bajtos Jan 21, 2026
53c49e5
Update AGENTS.md
bajtos Jan 21, 2026
abc7ecb
Merge branch 'master' into feat-egress-allowance
bajtos Feb 4, 2026
501b661
refactor(core): move FilBeam stats URL to chain config
bajtos Feb 4, 2026
a7f6bb4
Update apps/synapse-playground/src/lib/utils.ts
bajtos Feb 4, 2026
8071a70
test(core): add test for FilBeam unsupported chain error
bajtos Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/` `@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**:

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
@@ -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 })
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

The component doesn't handle loading or error states from the useEgressQuota hook. If the API request is slow or fails, the egress quota information will simply not appear, which could be confusing to users. Consider adding a loading skeleton (similar to the pattern used in services.tsx line 32-34) or an error state display to provide better user feedback. At minimum, showing a loading indicator while fetching would improve the user experience.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

How do we feel about this?

Are we okay to ship the current version, since it is still an improvement, or is it required to handle loading & error states before we can ship the remaining egress indicators?

@juliangruber @hugomrdias

Copy link
Member

Choose a reason for hiding this comment

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

I think this is good enough for the playground components


return (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Tooltip>
<TooltipTrigger>
<Globe className="w-4" />
</TooltipTrigger>
<TooltipContent>
<p>This data set is using CDN</p>
</TooltipContent>
</Tooltip>
{egressQuota && (
<>
Egress remaining: {formatBytes(egressQuota.cdnEgressQuota)} delivery{' · '}
{formatBytes(egressQuota.cacheMissEgressQuota)} cache-miss
</>
)}
</span>
)
}
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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({
Expand Down Expand Up @@ -109,16 +110,7 @@ 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>
)}
{dataSet.cdn && <CdnDetails dataSetId={dataSet.dataSetId} />}
</p>

{dataSet.pieces.map((piece) => (
Expand Down
35 changes: 35 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,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}`
}
7 changes: 7 additions & 0 deletions packages/synapse-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -136,6 +140,9 @@
"errors": [
"./dist/src/errors/index"
],
"filbeam": [
"./dist/src/filbeam/index"
],
"piece": [
"./dist/src/piece"
],
Expand Down
3 changes: 3 additions & 0 deletions packages/synapse-core/src/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface Chain extends ViemChain {
}
filbeam: {
retrievalDomain: string
statsBaseUrl: string
} | null
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -227,6 +229,7 @@ export const calibration: Chain = {
},
filbeam: {
retrievalDomain: 'calibration.filbeam.io',
statsBaseUrl: 'https://calibration.stats.filbeam.com',
},
testnet: true,
/**
Expand Down
1 change: 1 addition & 0 deletions packages/synapse-core/src/errors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/synapse-core/src/errors/filbeam.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
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'
134 changes: 134 additions & 0 deletions packages/synapse-core/src/filbeam/stats.ts
Original file line number Diff line number Diff line change
@@ -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<DataSetStats> {
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<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}`, {
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)
Comment on lines +71 to +90
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Following up on #538 (comment) and #538 (comment).

If we apply both suggestions, then we can simplify this block as follows - is that what we want?

  const response = await requestGetJson<unknown>(url)

  if (response.error) {
    throw new GetDataSetStatsError(
      'Cannot get DataSet stats from FilBeam API.', 
      { cause: response.error }
    )
  }

  return {
    cdnEgressQuota: BigInt(response.result.cdnEgressQuota),
    cacheMissEgressQuota: BigInt(response.result.cacheMissEgressQuota),
  }

}

/**
* 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<string, unknown>

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'),
}
}
Loading