Skip to content

Tolerate invalid stored proof payloads#336

Closed
yui-stingray wants to merge 1 commit into
ramimbo:mainfrom
yui-stingray:yui/bounty-292-small-fix
Closed

Tolerate invalid stored proof payloads#336
yui-stingray wants to merge 1 commit into
ramimbo:mainfrom
yui-stingray:yui/bounty-292-small-fix

Conversation

@yui-stingray
Copy link
Copy Markdown
Contributor

@yui-stingray yui-stingray commented May 26, 2026

Summary

  • Decode stored public proof payloads through shared serializer helpers so malformed JSON and non-object JSON are handled consistently.
  • Skip only the invalid proof row in aggregate public views such as bounty award history, activity, and account accepted-work responses.
  • Return controlled invalid-payload errors for direct proof lookups through the REST API and MCP get_proof.

Repro before fix

A stored bounty payment Proof.public_json value like { caused public aggregate views that decode proof rows to raise a server error instead of rendering the remaining valid accepted work. Non-object JSON payloads were also not covered explicitly.

Validation

  • uv run --extra dev python -m pytest tests/test_api_mcp.py::test_account_api_keeps_schema_when_accepted_work_proof_is_malformed tests/test_api_mcp.py::test_mcp_get_proof_rejects_malformed_stored_payload tests/test_activity.py::test_activity_views_skip_malformed_proof_payloads tests/test_bounty_pages.py::test_bounty_detail_skips_malformed_award_proof_payloads tests/test_bounty_pages.py::test_proof_api_reports_invalid_stored_payload -q --capture=no -> 6 passed
  • uv run --extra dev python -m pytest tests/test_api_mcp.py tests/test_activity.py tests/test_bounty_pages.py -q --capture=no -> 87 passed
  • uv run --extra dev ruff check app/main.py app/serializers.py tests/test_api_mcp.py tests/test_activity.py tests/test_bounty_pages.py -> passed
  • uv run --extra dev ruff format --check app/main.py app/serializers.py tests/test_api_mcp.py tests/test_activity.py tests/test_bounty_pages.py -> passed
  • uv run --extra dev python -m mypy app -> success
  • uv run --extra dev python -m pytest -q --capture=no -> 323 passed
  • git diff --check -> passed

Bounty

Refs #292

Notes:

No secrets, wallet private keys, payout credentials, deployment values, private vulnerability details, or MRWK price claims are included.

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for malformed proof payloads across API endpoints and activity/bounty views. Invalid proofs now return HTTP 500 errors instead of breaking page rendering.
  • Refactor

    • Consolidated proof payload parsing logic into reusable helper functions for consistency across bounty payouts, proof API responses, and MCP tools.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

This PR consolidates proof payload JSON parsing from scattered json.loads(proof.public_json) calls into two reusable helper functions and applies them across serializers, API endpoints, and MCP tools. Invalid or malformed payloads now consistently return None, triggering skips in serialization or HTTP 500 errors in endpoints.

Changes

Proof payload decoding refactor

Layer / File(s) Summary
Payload decoding helper functions
app/serializers.py
Introduces proof_public_payload(proof) to safely decode public_json with type validation, and bounty_payment_payload(proof) to filter decoded payloads by kind == "bounty_payment", both returning None on parse/type errors.
Serializer functions updated to use helpers
app/serializers.py
Refactors bounty_awards_to_dict, _activity_row, and accepted_work_for_account to call bounty_payment_payload instead of inline JSON parsing and kind checks, skipping proofs when the helper returns None.
Main.py endpoints and MCP tool refactored
app/main.py
Imports helpers and applies them to _payout_response_from_proof (bounty payout), api_proof (GET /api/v1/proofs/{hash}), and MCP get_proof tool, treating None returns as HTTP 500 "invalid proof payload" or JSON-RPC errors.
Test coverage for malformed payload handling
tests/test_activity.py, tests/test_api_mcp.py, tests/test_bounty_pages.py
Adds tests verifying that malformed proofs are skipped in activity and bounty detail views, that the proof API returns HTTP 500 for invalid payloads, and that MCP get_proof returns JSON-RPC error -32602 for corrupted stored payloads.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • ramimbo/mergework#330: Earlier refactor that extracted public serialization and proof formatting logic into app/serializers.py, which this PR builds upon by introducing centralized payload decoding helpers.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: handling invalid stored proof payloads gracefully.
Description check ✅ Passed The description includes all essential sections: a clear summary of changes, evidence of the problem being fixed, comprehensive test validation, and bounty reference.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_activity.py`:
- Around line 115-164: The test currently only simulates malformed JSON by
setting proof_row.public_json = "{"; add a second malformed-proof case that sets
public_json to a valid non-object JSON (e.g., "[]", "null" or a JSON string) to
exercise the non-object payload skip-path; create a second pay_bounty call
(similar to malformed_proof) or reuse session.get(Proof, ...) to mutate a proof
row for that proof (reference test_activity_views_skip_malformed_proof_payloads,
pay_bounty, Proof, session_scope) and then assert the aggregate API
(/api/v1/activity and /activity) still excludes both malformed proofs and that
totals/recent remain unchanged (ensure the new malformed proof hash is not
present in payload["recent"] and not in page.text).

In `@tests/test_api_mcp.py`:
- Around line 1132-1174: The test
test_mcp_get_proof_rejects_malformed_stored_payload only checks a malformed JSON
string ("{"); update it to also cover non-object JSON stored payloads (e.g.,
proof_row.public_json = "[]" and/or proof_row.public_json = "\"string\"" ) and
assert the same JSON-RPC error response; you can either add a second client
request after setting proof_row.public_json to "[]" (reusing the same
request/response assertions) or parametrize the test to iterate over both
payloads, keeping the rest of the setup (ensure_genesis, create_bounty,
pay_bounty, TestClient call, and expected error) unchanged.

In `@tests/test_bounty_pages.py`:
- Around line 299-345: The test
test_bounty_detail_skips_malformed_award_proof_payloads only mutates a Proof to
malformed JSON "{"; add a second Proof mutated to a non-object JSON payload "[]"
to exercise the non-object payload skip path. In the test create a second
pay_bounty call (e.g., malformed_proof_nonobject), fetch it via
session.get(Proof, malformed_proof_nonobject.hash) and set proof_row.public_json
= "[]", then include assertions similar to the existing ones to ensure that
malformed_proof_nonobject.hash is not present in page.text and not included in
api_detail.json()["accepted_awards"].
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a2e04d61-265a-4325-a3db-ae7f2e6f4fc7

📥 Commits

Reviewing files that changed from the base of the PR and between f4fa763 and d6c7019.

📒 Files selected for processing (5)
  • app/main.py
  • app/serializers.py
  • tests/test_activity.py
  • tests/test_api_mcp.py
  • tests/test_bounty_pages.py

Comment thread tests/test_activity.py
Comment on lines +115 to +164
def test_activity_views_skip_malformed_proof_payloads(sqlite_url: str) -> None:
create_schema(sqlite_url)
with session_scope(sqlite_url) as session:
ensure_genesis(session)
bounty = create_bounty(
session,
repo="ramimbo/mergework",
issue_number=12,
issue_url="https://github.com/ramimbo/mergework/issues/12",
title="Activity malformed proof bounty",
reward_mrwk="25",
max_awards=2,
acceptance="Activity should skip malformed proof rows.",
)
valid_proof = pay_bounty(
session,
bounty_id=bounty.id,
to_account="github:alice",
submission_url="https://github.com/ramimbo/mergework/pull/12",
accepted_by="maintainer",
verifier_result={"label": "mrwk:accepted"},
)
malformed_proof = pay_bounty(
session,
bounty_id=bounty.id,
to_account="github:bob",
submission_url="https://github.com/ramimbo/mergework/pull/13",
accepted_by="maintainer",
verifier_result={"label": "mrwk:accepted"},
)
proof_row = session.get(Proof, malformed_proof.hash)
assert proof_row is not None
proof_row.public_json = "{"

client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.get("/api/v1/activity")
page = client.get("/activity")

assert response.status_code == 200
assert page.status_code == 200
payload = response.json()
assert payload["totals"] == {
"accepted_awards": 1,
"accepted_mrwk": "25",
"contributors": 1,
}
assert [row["proof_hash"] for row in payload["recent"]] == [valid_proof.hash]
assert malformed_proof.hash not in page.text

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Cover non-object payloads in the activity skip-path test.

This test only exercises malformed JSON ("{"). The changed behavior also includes non-object JSON payloads; add that case here to guard the aggregate activity path.

Suggested update
+import pytest
...
+@pytest.mark.parametrize("public_json", ["{", "[]"])
-def test_activity_views_skip_malformed_proof_payloads(sqlite_url: str) -> None:
+def test_activity_views_skip_malformed_proof_payloads(
+    sqlite_url: str, public_json: str
+) -> None:
 ...
-        proof_row.public_json = "{"
+        proof_row.public_json = public_json

As per coding guidelines tests/**/*.py: Add or update tests for changed behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_activity.py` around lines 115 - 164, The test currently only
simulates malformed JSON by setting proof_row.public_json = "{"; add a second
malformed-proof case that sets public_json to a valid non-object JSON (e.g.,
"[]", "null" or a JSON string) to exercise the non-object payload skip-path;
create a second pay_bounty call (similar to malformed_proof) or reuse
session.get(Proof, ...) to mutate a proof row for that proof (reference
test_activity_views_skip_malformed_proof_payloads, pay_bounty, Proof,
session_scope) and then assert the aggregate API (/api/v1/activity and
/activity) still excludes both malformed proofs and that totals/recent remain
unchanged (ensure the new malformed proof hash is not present in
payload["recent"] and not in page.text).

Comment thread tests/test_api_mcp.py
Comment on lines +1132 to +1174
def test_mcp_get_proof_rejects_malformed_stored_payload(sqlite_url: str) -> None:
create_schema(sqlite_url)
with session_scope(sqlite_url) as session:
ensure_genesis(session)
bounty = create_bounty(
session,
repo="ramimbo/mergework",
issue_number=46,
issue_url="https://github.com/ramimbo/mergework/issues/46",
title="Malformed proof payload",
reward_mrwk="25",
acceptance="Proof payloads should be valid JSON.",
)
proof = pay_bounty(
session,
bounty_id=bounty.id,
to_account="github:alice",
submission_url="https://github.com/ramimbo/mergework/pull/46",
accepted_by="maintainer",
verifier_result={"label": "mrwk:accepted"},
)
proof_row = session.get(Proof, proof.hash)
assert proof_row is not None
proof_row.public_json = "{"

client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {"name": "get_proof", "arguments": {"hash": proof.hash}},
},
)

assert response.status_code == 200
assert response.json() == {
"jsonrpc": "2.0",
"id": 4,
"error": {"code": -32602, "message": "invalid tool arguments"},
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add non-object payload coverage to MCP get_proof invalid-stored-payload test.

The new test validates malformed JSON only. Please also cover non-object JSON (e.g., "[]") for this endpoint path.

Suggested update
+@pytest.mark.parametrize("public_json", ["{", "[]"])
-def test_mcp_get_proof_rejects_malformed_stored_payload(sqlite_url: str) -> None:
+def test_mcp_get_proof_rejects_malformed_stored_payload(
+    sqlite_url: str, public_json: str
+) -> None:
 ...
-        proof_row.public_json = "{"
+        proof_row.public_json = public_json

As per coding guidelines tests/**/*.py: Add or update tests for changed behavior.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_mcp_get_proof_rejects_malformed_stored_payload(sqlite_url: str) -> None:
create_schema(sqlite_url)
with session_scope(sqlite_url) as session:
ensure_genesis(session)
bounty = create_bounty(
session,
repo="ramimbo/mergework",
issue_number=46,
issue_url="https://github.com/ramimbo/mergework/issues/46",
title="Malformed proof payload",
reward_mrwk="25",
acceptance="Proof payloads should be valid JSON.",
)
proof = pay_bounty(
session,
bounty_id=bounty.id,
to_account="github:alice",
submission_url="https://github.com/ramimbo/mergework/pull/46",
accepted_by="maintainer",
verifier_result={"label": "mrwk:accepted"},
)
proof_row = session.get(Proof, proof.hash)
assert proof_row is not None
proof_row.public_json = "{"
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))
response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {"name": "get_proof", "arguments": {"hash": proof.hash}},
},
)
assert response.status_code == 200
assert response.json() == {
"jsonrpc": "2.0",
"id": 4,
"error": {"code": -32602, "message": "invalid tool arguments"},
}
`@pytest.mark.parametrize`("public_json", ["{", "[]"])
def test_mcp_get_proof_rejects_malformed_stored_payload(
sqlite_url: str, public_json: str
) -> None:
create_schema(sqlite_url)
with session_scope(sqlite_url) as session:
ensure_genesis(session)
bounty = create_bounty(
session,
repo="ramimbo/mergework",
issue_number=46,
issue_url="https://github.com/ramimbo/mergework/issues/46",
title="Malformed proof payload",
reward_mrwk="25",
acceptance="Proof payloads should be valid JSON.",
)
proof = pay_bounty(
session,
bounty_id=bounty.id,
to_account="github:alice",
submission_url="https://github.com/ramimbo/mergework/pull/46",
accepted_by="maintainer",
verifier_result={"label": "mrwk:accepted"},
)
proof_row = session.get(Proof, proof.hash)
assert proof_row is not None
proof_row.public_json = public_json
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))
response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {"name": "get_proof", "arguments": {"hash": proof.hash}},
},
)
assert response.status_code == 200
assert response.json() == {
"jsonrpc": "2.0",
"id": 4,
"error": {"code": -32602, "message": "invalid tool arguments"},
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_api_mcp.py` around lines 1132 - 1174, The test
test_mcp_get_proof_rejects_malformed_stored_payload only checks a malformed JSON
string ("{"); update it to also cover non-object JSON stored payloads (e.g.,
proof_row.public_json = "[]" and/or proof_row.public_json = "\"string\"" ) and
assert the same JSON-RPC error response; you can either add a second client
request after setting proof_row.public_json to "[]" (reusing the same
request/response assertions) or parametrize the test to iterate over both
payloads, keeping the rest of the setup (ensure_genesis, create_bounty,
pay_bounty, TestClient call, and expected error) unchanged.

Comment on lines +299 to +345
def test_bounty_detail_skips_malformed_award_proof_payloads(sqlite_url: str) -> None:
create_schema(sqlite_url)
with session_scope(sqlite_url) as session:
ensure_genesis(session)
bounty = create_bounty(
session,
repo="ramimbo/mergework",
issue_number=165,
issue_url="https://github.com/ramimbo/mergework/issues/165",
title="Malformed proof award history",
reward_mrwk="50",
max_awards=2,
acceptance="Bounty detail pages should skip malformed proof rows.",
)
valid_proof = pay_bounty(
session,
bounty_id=bounty.id,
to_account="github:alice",
submission_url="https://github.com/ramimbo/mergework/pull/203",
accepted_by="maintainer",
verifier_result={"label": "mrwk:accepted"},
)
malformed_proof = pay_bounty(
session,
bounty_id=bounty.id,
to_account="github:bob",
submission_url="https://github.com/ramimbo/mergework/pull/204",
accepted_by="maintainer",
verifier_result={"label": "mrwk:accepted"},
)
proof_row = session.get(Proof, malformed_proof.hash)
assert proof_row is not None
proof_row.public_json = "{"
bounty_id = bounty.id

client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

api_detail = client.get(f"/api/v1/bounties/{bounty_id}")
page = client.get(f"/bounties/{bounty_id}")

assert api_detail.status_code == 200
assert page.status_code == 200
assert [award["proof_hash"] for award in api_detail.json()["accepted_awards"]] == [
valid_proof.hash
]
assert malformed_proof.hash not in page.text

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Also exercise non-object payloads in bounty award-history skip behavior.

This new aggregate-view test mutates only to malformed JSON ("{"). Add a non-object payload case ("[]") to cover the full changed payload contract.

Suggested update
+@pytest.mark.parametrize("public_json", ["{", "[]"])
-def test_bounty_detail_skips_malformed_award_proof_payloads(sqlite_url: str) -> None:
+def test_bounty_detail_skips_malformed_award_proof_payloads(
+    sqlite_url: str, public_json: str
+) -> None:
 ...
-        proof_row.public_json = "{"
+        proof_row.public_json = public_json

As per coding guidelines tests/**/*.py: Add or update tests for changed behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_bounty_pages.py` around lines 299 - 345, The test
test_bounty_detail_skips_malformed_award_proof_payloads only mutates a Proof to
malformed JSON "{"; add a second Proof mutated to a non-object JSON payload "[]"
to exercise the non-object payload skip path. In the test create a second
pay_bounty call (e.g., malformed_proof_nonobject), fetch it via
session.get(Proof, malformed_proof_nonobject.hash) and set proof_row.public_json
= "[]", then include assertions similar to the existing ones to ensure that
malformed_proof_nonobject.hash is not present in page.text and not included in
api_detail.json()["accepted_awards"].

Copy link
Copy Markdown
Contributor

@TateLyman TateLyman left a comment

Choose a reason for hiding this comment

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

Reviewed the invalid proof-payload handling in PR #336. The implementation change looks consistent: aggregate public views now go through the shared proof_public_payload / bounty_payment_payload helpers and skip bad stored proof rows, while direct proof lookup paths return controlled invalid-payload errors instead of leaking JSON decode failures.

One remaining coverage gap is worth fixing before merge: the aggregate skip-path tests only mutate stored public_json to malformed JSON ({), but the helper also intentionally treats valid non-object JSON, such as [], as invalid. Direct proof lookup covers both via test_proof_api_reports_invalid_stored_payload, but activity, bounty award history, and MCP get_proof should get the same non-object regression coverage so the full payload contract is locked down. This matches the three CodeRabbit comments I checked against the current diff.

Validation run locally on this PR head:

  • uv run --extra dev python -m pytest tests/test_api_mcp.py::test_account_api_keeps_schema_when_accepted_work_proof_is_malformed tests/test_api_mcp.py::test_mcp_get_proof_rejects_malformed_stored_payload tests/test_activity.py::test_activity_views_skip_malformed_proof_payloads tests/test_bounty_pages.py::test_bounty_detail_skips_malformed_award_proof_payloads tests/test_bounty_pages.py::test_proof_api_reports_invalid_stored_payload -q -> 6 passed
  • uv run --extra dev python -m pytest tests/test_api_mcp.py tests/test_activity.py tests/test_bounty_pages.py -q -> 87 passed
  • uv run --extra dev python -m pytest tests/test_api_mcp.py tests/test_activity.py tests/test_bounty_pages.py tests/test_serializers.py -q -> 89 passed
  • uv run --extra dev ruff check app/main.py app/serializers.py tests/test_activity.py tests/test_api_mcp.py tests/test_bounty_pages.py -> passed
  • uv run --extra dev ruff format --check app/main.py app/serializers.py tests/test_activity.py tests/test_api_mcp.py tests/test_bounty_pages.py -> passed
  • uv run --extra dev python -m mypy app/main.py app/serializers.py -> success
  • uv run --extra dev python scripts/docs_smoke.py -> docs smoke ok
  • git diff --check origin/main...HEAD -> clean

No secrets, wallet keys, payout credentials, deployment values, private vulnerability details, or MRWK price/cash-out claims were reviewed or exposed.

@ramimbo
Copy link
Copy Markdown
Owner

ramimbo commented May 26, 2026

Maintainer update: holding this with mrwk:needs-info. The behavior is useful, but before merge please cover the non-object stored proof payload path in the aggregate activity/bounty views and MCP/direct proof paths called out in review: #336 (review). No PR bounty payout is recorded while it is held.

@ramimbo
Copy link
Copy Markdown
Owner

ramimbo commented May 26, 2026

Closing this cleanup item now that #292 is filled. This PR was already held with mrwk:needs-info, and no fresh small-fixes round is being opened from this pass.

@ramimbo ramimbo closed this May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mrwk:needs-info More information needed mrwk:rejected Submission rejected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants