linera-io/linera-protocol · warning · WorkerError

UnexpectedBlob

UnexpectedBlob

Error message

Blob was not required by any pending block

What it means

Validators only store blobs that a pending block actually requires. handle_pending_blob inserts into pending_validated_blobs and every pending_proposed_blobs entry via maybe_insert, which returns true only when the blob id was already announced by a validated block or by a proposal's expected blob ids. If no pending block lists the blob, the request fails with UnexpectedBlob, so validators cannot be used as arbitrary blob storage.

Solutions

  1. Send the block proposal (or certificate) first; only then upload the blobs it announces.
  2. Filter uploads against the proposal's expected_blob_ids.
  3. On retries after a round change, resend the updated proposal before resending blobs.

Example fix

// before: pushing blobs before anything references them
for blob in blobs {
    client.upload_blob(chain_id, blob).await?;
}
client.submit_proposal(proposal).await?;

// after: the proposal announces the blob ids, then the blobs follow
client.submit_proposal(proposal).await?;
for blob in blobs {
    client.upload_blob(chain_id, blob).await?;
}
Defensive patterns

Strategy: validation

Validate before calling

// Only upload blobs the pending proposal actually requires.
let expected: HashSet<BlobId> = proposal.expected_blob_ids().iter().cloned().collect();
for blob in &blobs {
    ensure!(expected.contains(&blob.id()), WorkerError::UnexpectedBlob);
}

Try / catch

match node.handle_pending_blob(blob).await {
    Err(NodeError::WorkerError(err)) if matches!(*err, WorkerError::UnexpectedBlob) => {
        // Not required (yet): submit the proposal first, then retry the upload.
    }
    result => result,
}

Prevention

When it happens

Trigger: Calling handle_pending_blob (directly, via send_pending_blobs, or with a handle_message carrying a blob) before the proposal or certificate that references the blob has arrived; uploading a blob the proposal does not list in its expected blob ids; resending blobs after a round change cleared the pending set.

Common situations: Custom clients that pre-upload blobs eagerly; ordering bugs where the blob upload races the block proposal; retries after round changes that reset pending_blobs.

Related errors


AI-assisted analysis of linera-io/linera-protocol@6c226ddcb3 (2026-08-22). Data as JSON: /api/errors/819859b42fb42383. Report an issue: GitHub.

Appendix: source

Thrown at linera-core/src/chain_worker/state.rs:2288

            .pending_proposed_blobs
            .try_load_all_entries_mut()
            .await?
        {
            if !pending_blobs.validated.get() {
                let (_, committee) = self.chain.current_committee().await?;
                let policy = committee.policy();
                policy
                    .check_blob_size(blob.content())
                    .with_execution_context(ChainExecutionContext::Block)?;
                ensure!(
                    u64::try_from(pending_blobs.pending_blobs.iterative_count().await?)
                        .is_ok_and(|count| count < policy.maximum_published_blobs),
                    WorkerError::TooManyPublishedBlobs(policy.maximum_published_blobs)
                );
            }
            was_expected = was_expected || pending_blobs.maybe_insert(&blob).await?;
        }
        ensure!(was_expected, WorkerError::UnexpectedBlob);
        self.save().await?;
        self.chain_info_response().await
    }

    /// Returns a stored [`Certificate`] for the chain's block at the requested [`BlockHeight`].
    ///
    /// Does not need `&mut self` because the chain is eagerly initialized when the
    /// chain handle is created.
    #[cfg(with_testing)]
    #[instrument(skip_all, fields(
        chain_id = %self.chain_id(),
        height = %height
    ))]
    pub(crate) async fn read_certificate(
        &self,
        height: BlockHeight,
    ) -> Result<Option<CacheArc<ConfirmedBlockCertificate>>, WorkerError> {
        let certificate_hash = match self.chain.block_hashes.get(&height).await? {

View on GitHub (pinned to 6c226ddcb3)