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.