Skip to content

challenge aggregatedComment extras dropped from DB row, breaks page CID reconstruction #103

@Rinse12

Description

@Rinse12

Summary

A challenge that returns { success: true, comment: { someKey: ... } } (PR #88 / #99 path, shipped in 0.0.37) attaches someKey to the IPFS-bound CommentIpfs literal, but the key is dropped from the comment table row's extraProps. When the community later rebuilds the comment from the DB to embed in commentUpdate.replies.pages.best.comments[].comment, the rebuilt bytes do not match the stored commentUpdate.cid, so any remote verifier syncing pages fails with:

PKCError: CommentUpdate signature is invalid
code: ERR_COMMENT_UPDATE_SIGNATURE_IS_INVALID
reason: CommentUpdate.cid is different than calculated or provided comment.cid

The whole community then permanently fails to sync.

Reproduction in the wild

A live community running 0.0.37 installed a flag challenge that returns { success: true, comment: { "<namespace>": { ...flagAssertion } } }. After a handful of "test flag" replies, the community went into a permanent failed-sync loop. Affected community: 12D3KooWNFgjQWX2EUEs7pixdjkWSLh21EZ9NeYnV8iMaCyYhLGJ. Full incident dump (logs, evidence) is captured locally and excerpted below.

Reply CIDs from the failing sync block (all replies to the same post QmPiHzKKq7LQoASG44QmRGc8HygCmvSggiyg7iJRDiZysd):

  • QmV6FmWyt2MZr2FsZg51MrZJ4C6PuYASdxnn6M4q6SUYkN (content: "test flag")
  • QmdgnoPLEprDHtbgmkEe62839hmahNhXmqNiAQJXyZEvUS (content: "test again")
  • QmUPeP2cxCeyinLBgPAynfG8FL6c9z1Koxhd1jPXJKwNXh (content: "test")
  • QmWWEBrt7vZi8iJ8zLjoNVqaiTTBsviLJvUw5RvgxtWdiT (content: "test flag")

Root cause

In src/runtime/node/community/local-community/publication-store.ts storeComment():

  1. commentIpfs is built with ...(challengeAggregate?.aggregatedComment ?? {}) spread last. The challenge-added key lands in the bytes that get ipfs.add'd and hashed. commentCid is computed over the full object.
  2. CommentIpfsSchema.strip().parse(commentIpfs) strips the challenge-added key (not in schema).
  3. commentRow is built from the stripped object plus the full commentCid.
  4. Bug: unknownProps = remeda.difference(remeda.keys.strict(commentPubsub), remeda.keys.strict(CommentPubsubMessagePublicationSchema.shape)). The diff is against commentPubsub, not the hashed commentIpfs. Since the challenge-added key was spread on top of commentPubsub from aggregatedComment, it is never in this diff. commentRow.extraProps therefore does not contain it.

deriveCommentIpfsFromCommentTableRow in src/runtime/node/util.ts rebuilds the comment as { ...commentPubsub, ...commentIpfs, ...extraProps }. With the challenge key missing from the row, the rebuilt object lacks it. Page verification re-hashes the rebuilt comment and fails to match the stored commentUpdate.cid.

extraProps is the only DB-row mechanism that allows a non-schema key to survive into a rebuilt page comment (the parallel subplebbitAddress migration in docs/protocol/db-community-address-migration.md uses the same mechanism).

The aggregatedCommentUpdate path (stored verbatim in the new challengeCommentUpdate column) is unaffected.

Fix

In storeComment, take the unknownProps diff against the literal that was actually hashed (commentIpfs) versus CommentIpfsSchema.shape:

const unknownProps = remeda
    .difference(remeda.keys.strict(commentIpfs), remeda.keys.strict(CommentIpfsSchema.shape))
    .filter((key) => (key as string) !== "communityAddress");
if (unknownProps.length > 0) {
    log("Found extra props on Comment", unknownProps, "Will be adding them to extraProps column");
    commentRow.extraProps = remeda.pick(commentIpfs, unknownProps);
}

Single source of truth: the bytes that were hashed. Generalizes beyond aggregatedComment to any future code path that adds keys before calculateIpfsCidV0. Cannot corrupt the author signature because validateChallengeResultExtras already blocks challenges from setting any key in CommentSignedPropertyNames.

Test plan

Test-first per AGENTS.md.

Integration test (new file test/node/community/challenges/aggregated-comment-extras.community.test.ts):

  1. Register a custom challenge on pkc.settings.challenges["flag-attach"] whose getChallenge returns { success: true, comment: { "5chan": { ...assertion } } }.
  2. Create + start a LocalCommunity with settings.challenges = [{ name: "flag-attach" }].
  3. Publish a post, then publish a reply (so the post page contains the reply as a page comment).
  4. Wait for the post's updatedAt to advance.
  5. From a remote PKC, getCommunity({ address }) and read the post's preloaded replies.pages.best.
  6. Call verifyPage({ pageCid, pageSortName, page, ... }) from src/signer/signatures.ts, assert valid: true.
  7. Assert the reply's comment["5chan"] round-tripped intact.

Must fail with ERR_COMMENT_UPDATE_DIFFERENT_CID_THAN_COMMENT (or its wrapper) on master before the fix; pass after.

Unit guard: extend test/challenges/result-extras.test.ts with a storeComment direct call (in-memory DB) that asserts extraProps?.["5chan"] is preserved.

Out of scope (separate follow-ups)

  • bitsocial community list failing whole-table on one bad community — downstream concern in @bitsocial/bitsocial-cli.
  • Already-corrupted DB rows on live nodes need a one-off migration that re-derives the CID and prunes mismatches.
  • storeCommentEdit / storeCommentModeration / storeVote would have the same bug if aggregatedCommentEdit / aggregatedVote are ever added — currently safe.
  • challengeCommentUpdate JSON column has no dedicated parsing test in test/node/community/parsing.db.community.test.ts (AGENTS.md SHOULD rule).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions