Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Ref: FRC-0069

**Internal**: `MetadataEntry[]` (alphabetically sorted for EIP-712)

**Validation**: Data sets max 10 keys, pieces max 5 keys, keys max 32 chars, values max 128 chars. Validated early in PDPServer.
**Validation**: Data sets max 10 keys, pieces max 3 keys, keys max 32 chars, values max 96 chars. Validated early in PDPServer.

**Security**: Uses `Object.create(null)` for prototype-safe objects from contracts.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This page covers data set management, retrieval, and lifecycle operations. For u
**Metadata**: Optional key-value pairs for organization:

- **Data Set Metadata**: Max 10 keys (e.g., `project`, `environment`)
- **Piece Metadata**: Max 5 keys per piece (e.g., `filename`, `contentType`)
- **Piece Metadata**: Max 3 keys per piece (e.g., `filename`, `contentType`)

**Copies and Durability**: By default, `upload()` stores your data with 2 independent providers. Each provider maintains its own data set with separate PDP proofs and payment rails. If one provider goes down, your data is still available from the other.

Expand Down
11 changes: 11 additions & 0 deletions packages/synapse-core/src/errors/warm-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,14 @@ export class AtLeastOnePieceRequiredError extends SynapseError {
return isSynapseError(value) && value.name === 'AtLeastOnePieceRequiredError'
}
}

export class TooManyPiecesError extends SynapseError {
override name: 'TooManyPiecesError' = 'TooManyPiecesError'
constructor(count: number, max: number) {
super(`Too many pieces: ${count}, max ${max} per batch. Split into smaller batches.`)
}
Comment thread
rvagg marked this conversation as resolved.

static override is(value: unknown): value is TooManyPiecesError {
return isSynapseError(value) && value.name === 'TooManyPiecesError'
}
}
25 changes: 20 additions & 5 deletions packages/synapse-core/src/sp/add-pieces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { type Account, type Chain, type Client, type Hex, isHex, type Transport
import * as z from 'zod'
import { AddPiecesError, LocationHeaderError } from '../errors/index.ts'
import { WaitForAddPiecesError, WaitForAddPiecesRejectedError } from '../errors/pdp.ts'
import { AtLeastOnePieceRequiredError } from '../errors/warm-storage.ts'
import { AtLeastOnePieceRequiredError, TooManyPiecesError } from '../errors/warm-storage.ts'
import type { PieceCID } from '../piece/piece-cid.ts'
import { signAddPieces } from '../typed-data/sign-add-pieces.ts'
import { RETRY_CONSTANTS } from '../utils/constants.ts'
import { RETRY_CONSTANTS, SIZE_CONSTANTS } from '../utils/constants.ts'
import { type MetadataObject, pieceMetadataObjectToEntry } from '../utils/metadata.ts'
import { zHex, zNumberToBigInt } from '../utils/schemas.ts'

Expand Down Expand Up @@ -107,6 +107,23 @@ export namespace addPieces {
export type ErrorType = addPiecesApiRequest.ErrorType | signAddPieces.ErrorType
}

/**
* Validate the piece count for an addPieces (or createDataSetAndAddPieces) batch,
* failing early instead of reverting on-chain.
*
* @param pieceCount - Number of pieces in the batch
* @throws AtLeastOnePieceRequiredError when not a positive integer
* @throws TooManyPiecesError when above {@link SIZE_CONSTANTS.MAX_ADD_PIECES_BATCH_SIZE}
*/
export function validateAddPiecesBatch(pieceCount: number): void {
if (!Number.isInteger(pieceCount) || pieceCount < 1) {
throw new AtLeastOnePieceRequiredError()
}
if (pieceCount > SIZE_CONSTANTS.MAX_ADD_PIECES_BATCH_SIZE) {
Comment thread
rvagg marked this conversation as resolved.
throw new TooManyPiecesError(pieceCount, SIZE_CONSTANTS.MAX_ADD_PIECES_BATCH_SIZE)
}
}

/**
* Add pieces to a data set
*
Expand All @@ -121,9 +138,7 @@ export async function addPieces(
client: Client<Transport, Chain, Account>,
options: addPieces.OptionsType
): Promise<addPieces.OutputType> {
if (options.pieces.length === 0) {
throw new AtLeastOnePieceRequiredError()
}
validateAddPiecesBatch(options.pieces.length)
const extraData =
options.extraData ??
(await signAddPieces(client, {
Expand Down
3 changes: 2 additions & 1 deletion packages/synapse-core/src/sp/create-dataset-add-pieces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { PieceCID } from '../piece/piece-cid.ts'
import { signCreateDataSetAndAddPieces } from '../typed-data/sign-create-dataset-add-pieces.ts'
import { RETRY_CONSTANTS } from '../utils/constants.ts'
import { datasetMetadataObjectToEntry, type MetadataObject, pieceMetadataObjectToEntry } from '../utils/metadata.ts'
import { waitForAddPieces } from './add-pieces.ts'
import { validateAddPiecesBatch, waitForAddPieces } from './add-pieces.ts'
import { waitForCreateDataSet } from './create-dataset.ts'

export namespace createDataSetAndAddPiecesApiRequest {
Expand Down Expand Up @@ -134,6 +134,7 @@ export async function createDataSetAndAddPieces(
client: Client<Transport, Chain, Account>,
options: CreateDataSetAndAddPiecesOptions
): Promise<createDataSetAndAddPieces.ReturnType> {
validateAddPiecesBatch(options.pieces.length)
const chain = asChain(client.chain)
const extraData =
options.extraData ??
Expand Down
8 changes: 8 additions & 0 deletions packages/synapse-core/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ export const SIZE_CONSTANTS = {
*/
DEFAULT_UPLOAD_BATCH_SIZE: 32,

/**
* Maximum pieces per addPieces (or createDataSetAndAddPieces) call.
*
* On-chain limitations fail batch sizes above 41; we constrain to 40 here to
* catch those failures early and surface informative errors.
*/
MAX_ADD_PIECES_BATCH_SIZE: 40,
Comment on lines +94 to +100
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

intentional

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is 1 a good margin? Would 2 be better? What if we use no safety margin?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's not really intended as a margin, it's intended as a round number cause exposing 41 invites more questions .. but I guess the comment also invites questions. I was trying to be vague so as to not explain the details too much. We know >41 breaks the fvm event limit. We are relatively confident that fully-loaded metadata now shouldn't break ~40 with gas. I have a small amount of confidence that create+add with fully loaded metadata shouldn't break it but I need to wait till we have everything merged and set up before I can measure that—but even then, gas on mainnet is very different to devnet and different still from calibnet so we're doing a bit of stabbing in the dark without actually running it on mainnet.
If we need to come back and lower it, then I'd do it in increments of 5, just to make the number not look to odd.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Got it! I don't think 40 is better than 41 here, it's as magic a number. If we decide to keep 40, I suggest to add to the document that we're doing this to not raise suspicion


/**
* Bytes per leaf in the PDP merkle tree.
* The FWSS contract converts leaf counts to bytes via `totalBytes = leafCount * BYTES_PER_LEAF`.
Expand Down
4 changes: 2 additions & 2 deletions packages/synapse-core/src/utils/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ export type MetadataObject = Record<string, string>
// Metadata size and count limits from the contract
export const METADATA_LIMITS = {
MAX_KEY_LENGTH: 32,
MAX_VALUE_LENGTH: 128,
MAX_VALUE_LENGTH: 96,
MAX_KEYS_PER_DATASET: 10,
MAX_KEYS_PER_PIECE: 5,
MAX_KEYS_PER_PIECE: 3,
}

export function metadataArrayToObject(metadataArray: MetadataArray): Record<string, string> {
Expand Down
26 changes: 26 additions & 0 deletions packages/synapse-core/test/add-pieces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from 'assert'
import { AtLeastOnePieceRequiredError, TooManyPiecesError } from '../src/errors/warm-storage.ts'
import { validateAddPiecesBatch } from '../src/sp/add-pieces.ts'
import { SIZE_CONSTANTS } from '../src/utils/constants.ts'

describe('validateAddPiecesBatch', () => {
const max = SIZE_CONSTANTS.MAX_ADD_PIECES_BATCH_SIZE

it('should throw when empty', () => {
assert.throws(() => validateAddPiecesBatch(0), AtLeastOnePieceRequiredError)
})

it('should throw for non-positive or non-integer counts', () => {
for (const bad of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
assert.throws(() => validateAddPiecesBatch(bad), AtLeastOnePieceRequiredError)
}
})

it('should accept a count at the maximum', () => {
assert.doesNotThrow(() => validateAddPiecesBatch(max))
})

it('should throw when above the maximum', () => {
assert.throws(() => validateAddPiecesBatch(max + 1), TooManyPiecesError)
})
})
4 changes: 2 additions & 2 deletions packages/synapse-core/test/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,9 @@ describe('Metadata Utils', () => {
describe('METADATA_LIMITS', () => {
it('should have expected limit values', () => {
assert.equal(METADATA_LIMITS.MAX_KEY_LENGTH, 32)
assert.equal(METADATA_LIMITS.MAX_VALUE_LENGTH, 128)
assert.equal(METADATA_LIMITS.MAX_VALUE_LENGTH, 96)
assert.equal(METADATA_LIMITS.MAX_KEYS_PER_DATASET, 10)
assert.equal(METADATA_LIMITS.MAX_KEYS_PER_PIECE, 5)
assert.equal(METADATA_LIMITS.MAX_KEYS_PER_PIECE, 3)
})
})
})
8 changes: 7 additions & 1 deletion packages/synapse-sdk/src/storage/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ export class StorageContext {
* @returns Signed extraData hex to pass to pull() or commit()
*/
async presignForCommit(pieces: Array<{ pieceCid: PieceCID; pieceMetadata?: MetadataObject }>): Promise<Hex> {
SP.validateAddPiecesBatch(pieces.length)
const signingPieces = pieces.map((p) => ({
pieceCid: p.pieceCid,
metadata: pieceMetadataObjectToEntry(p.pieceMetadata),
Expand Down Expand Up @@ -738,6 +739,10 @@ export class StorageContext {
async pull(options: PullOptions): Promise<PullResult> {
const { pieces, from, signal, onProgress, extraData } = options

// The SP estimateGas-validates the eventual addPieces, so an oversized batch
// fails there too; reject early for a clear error on non-presigned paths.
SP.validateAddPiecesBatch(pieces.length)

const getSourceUrl = (pieceCid: PieceCID): string => {
if (typeof from === 'string') {
return createPieceUrlPDP({ cid: pieceCid.toString(), serviceURL: from })
Expand Down Expand Up @@ -820,7 +825,8 @@ export class StorageContext {
async commit(options: CommitOptions): Promise<CommitResult> {
const { pieces, extraData } = options

// Validate metadata early
// Validate batch size and metadata early, before any chain reads or signing
SP.validateAddPiecesBatch(pieces.length)
for (const piece of pieces) {
if (piece.pieceMetadata) {
pieceMetadataObjectToEntry(piece.pieceMetadata)
Expand Down
48 changes: 48 additions & 0 deletions packages/synapse-sdk/src/test/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Chain, calibration } from '@filoz/synapse-core/chains'
import { TooManyPiecesError } from '@filoz/synapse-core/errors'
import * as Mocks from '@filoz/synapse-core/mocks'
import * as Piece from '@filoz/synapse-core/piece'
import { calculate, calculate as calculatePieceCID } from '@filoz/synapse-core/piece'
Expand Down Expand Up @@ -892,6 +893,53 @@ describe('StorageService', () => {
})
})

describe('addPieces batch limit', () => {
const tooMany = SIZE_CONSTANTS.MAX_ADD_PIECES_BATCH_SIZE + 1

async function makeContext() {
server.use(Mocks.JSONRPC({ ...Mocks.presets.basic }), Mocks.PING())
const synapse = new Synapse({ client, source: null })
const warmStorageService = new WarmStorageService({ client })
return StorageContext.create({ synapse, warmStorageService })
}

it('presignForCommit rejects batches above the limit', async () => {
const service = await makeContext()
const pieceCid = await calculate(new Uint8Array(127).fill(1))
const pieces = Array.from({ length: tooMany }, () => ({ pieceCid }))
try {
await service.presignForCommit(pieces)
assert.fail('Should have thrown')
} catch (error) {
assert.instanceOf(error, TooManyPiecesError)
}
})

it('commit rejects batches above the limit', async () => {
const service = await makeContext()
const pieceCid = await calculate(new Uint8Array(127).fill(1))
const pieces = Array.from({ length: tooMany }, () => ({ pieceCid }))
try {
await service.commit({ pieces })
assert.fail('Should have thrown')
} catch (error) {
assert.instanceOf(error, TooManyPiecesError)
}
})

it('pull rejects batches above the limit', async () => {
const service = await makeContext()
const pieceCid = await calculate(new Uint8Array(127).fill(1))
const pieces = Array.from({ length: tooMany }, () => pieceCid)
try {
await service.pull({ pieces, from: 'https://pdp.example.com' })
assert.fail('Should have thrown')
} catch (error) {
assert.instanceOf(error, TooManyPiecesError)
}
})
})

describe('upload', () => {
it('should handle errors in batch processing gracefully', async () => {
server.use(
Expand Down
Loading