Skip to content

Instantly share code, notes, and snippets.

@denniswon
Created April 15, 2026 21:23
Show Gist options
  • Select an option

  • Save denniswon/8ca54e7621adf642e5948676c490d9d1 to your computer and use it in GitHub Desktop.

Select an option

Save denniswon/8ca54e7621adf642e5948676c490d9d1 to your computer and use it in GitHub Desktop.
Octane Critical Findings Resolution
⏺ Review posted: https://github.com/newt-foundation/newton-prover-avs/pull/541#pullrequestreview-4116329428
Three inline comments, state CHANGES_REQUESTED:
1. Gap 1 (blocker) on ChallengeVerifier.sol:156 — Critical 4 is only half-fixed. The hash-encoding mismatch is resolved, but the
proof-to-canonical-code binding (sub-issue 5b in Octane's report) remains open. raiseAndResolveChallenge and
slashForCrossChainChallenge bind to policy.getEntrypoint() but never to policyCid or equivalent code digest — a malicious challenger
can craft a proof evaluating different Rego code sharing the same entrypoint and slash honest operators.
2. Gap 6 (high) on ChallengeVerifier.sol:513 — after the try/catch in challengeDirectlyVerifiedMismatch, the slashing path decodes
NonSignerStakesAndSignature from untrusted signatureData. Unlike raiseAndResolveChallenge, there's no validateSignatoryRecord bind — a
truncated nonSignerPubkeys array lets an attacker slash honest non-signers. Suggested invalidating the attestation without slashing,
since wrongful-slashing DoS is exactly the bug class this PR is trying to close.
3. Gap 8 (nit) on TaskManagerStorage.sol:101 — cross-reference comment on allTaskResponses pointing at the new
allNormalizedTaskResponses mapping.
★ Insight ─────────────────────────────────────
- GitHub's API returned 422 "Line could not be resolved" on the first attempt because inline comments must anchor to lines inside a
diff hunk (added or modified). Unchanged surrounding context lines aren't addressable — line 162 and 523 sit outside the hunks that
were touched. Re-anchoring to lines 156 and 513 (which are inside the @@ -148,8 +148,12 @@ and @@ -493,9 +499,18 @@ hunks) resolved it.
- Gap 1 is the most consequential finding because it contradicts the PR description's "remediates all 4 critical findings" claim. The
ChallengeVerifier's policy binding is purely by entrypoint name — a semantic shortcut that the SP1 circuit must compensate for. If the
circuit doesn't pin policyCid in its public outputs, the on-chain check has no way to detect wrong-code proofs, and this PR leaves the
economic-security hole from Critical 4 half-open.
─────────────────────────────────────────────────
✻ Churned for 4m 49s · 1 shell still running
※ recap: Reviewing PR #541 (Octane critical fixes) against the original audit reports. Posted a REQUEST_CHANGES follow-up with three
inline comments flagging the unaddressed Critical 4 policyCid binding, over-slashing via untrusted signatureData, and a doc nit.
Waiting on PR author response. (disable recaps in /config)
❯ I just checked out branch fix/octane-critical-findings. Let's implement the fixes / changes required for this PR that we just
reviewed. /clarify-plan
⏺ Skill(/clarify-plan)
⎿  Successfully loaded skill
⏺ Before I start implementing, I need to clarify scope and approach on the findings from our review. Let me ask three targeted questions
to make sure I build the right fix.
★ Insight ─────────────────────────────────────
- The three gaps we posted have very different blast radii: Gap 1 (policyCid binding) is a protocol-layer question that may require SP1
circuit changes, while Gap 6 (over-slashing) is a local contract fix. Clarifying scope first prevents me from silently expanding the
PR.
- Gap 1 in particular has two valid resolutions (on-chain check vs. documented circuit invariant) — picking between them depends on
what the SP1 circuit already commits to, which I haven't audited yet.
─────────────────────────────────────────────────
Batch 1 — Scope and Gap 1 approach
1. Scope: Should I address all three gaps from our follow-up review (Gap 1 blocker, Gap 6 high, Gap 8 nit), OR also incorporate items
from the earlier initial review (zero regression tests, storage layout baseline regeneration, bare catch { } OOG risk on line 511, weak
taskId == taskId binding in AttestationValidator:246, // 7. numbering nit, interface NatSpec nit)? I want to know whether this PR
grows into a full remediation pass or stays narrowly on the three new findings.
2. Gap 1 resolution path: For the policyCid binding, do you want me to (a) add an on-chain context.policyCid == policy.getPolicyCid()
check in ChallengeVerifier — which requires IRegoVerifier.RegoContext to expose a policyCid field and the SP1 circuit to commit it as a
public output, OR (b) audit the SP1 circuit (circuits/sp1-rego/) to confirm it already pins the program digest to public outputs, then
document the invariant in a comment and leave the contract alone? Option (a) is safer but touches the circuit + bindings +
NewtonPolicyData; option (b) is cheaper but trusts the circuit author. Which do you prefer as a starting assumption?
3. INewtonPolicy.getPolicyCid() availability: Does the existing INewtonPolicy / NewtonPolicyData contract already expose a policyCid
(or equivalent content hash of the Rego program)? I'll grep to confirm, but if you already know — is this a field we store at policy
creation time, or would adding it require a policy-contract storage change that breaks the upgrade story for already-deployed policies?
Once I have answers on these, I can ask the second batch about Gap 6 (whether to remove slashing or add signature binding) and
testing/regression coverage.
※ recap: Implementing the three review fixes on PR #541's branch `fix/octane-critical-findings` (Gap 1 policyCid binding, Gap 6
over-slashing, Gap 8 doc nit). Waiting on your answers to the scope and Gap 1 resolution questions before coding.
❯ 1. we need to resolve EVERYTHING 2. let's first do (b) to confirm the issue. If the issue is there, then we should do (a) 3. grep to
confirm For Gap 6, help me decide. which one do you suggest? what are pros vs cons?
⏺ Let me investigate in parallel: grep for policyCid in the policy contracts, and read the SP1 circuit to see what it commits to.
Searched for 4 patterns, read 2 files (ctrl+o to expand)
⎿  Loaded contracts/src/interfaces/CLAUDE.md
⏺ Investigation complete. I have definitive answers on Gap 1 and can now give you a concrete Gap 6 recommendation.
Gap 1 Investigation Result: Option (a) is required
★ Insight ─────────────────────────────────────
- The SP1 circuit reads the Rego policy bytes directly from task_response.policyTaskData.policy (line 60) — caller-supplied input. It
evaluates whatever bytes the prover hands it. No verification against a canonical code digest anywhere.
- RegoContext public outputs (line 70-75) commit to task, taskResponse, entrypoint, evaluation — but NOT to policyCid or any hash of
the policy code itself. The SP1 STARK proves "evaluating these bytes with this entrypoint against this taskResponse yields this
evaluation" — it proves nothing about which code those bytes represent.
- INewtonPolicy.getPolicyCid() exists and returns a string (IPFS CID). NewtonPolicy.sol:18 stores string public policyCid. So the
canonical digest is available on-chain — the circuit just never binds to it.
─────────────────────────────────────────────────
The attack (concretely):
1. Honest operators evaluate policy at CID Qm_Honest → evaluationResult = "allow", signed via BLS → stored in allTaskResponses
2. Malicious challenger crafts a proof by substituting task_response.policyTaskData.policy with doctored Rego code that evaluates to
"deny" for the same intent
3. SP1 generates a valid proof: context = { task, taskResponse (with doctored policyTaskData.policy), entrypoint, evaluation="deny" }
4. On-chain: TaskLib.taskHash(context.task) == allTaskHashes[taskId] ✓ (task is unchanged), keccak256(abi.encode(context.taskResponse))
== keccak256(abi.encode(taskResponse)) ✓ (challenger passes the same doctored response), getEntrypoint() == context.entrypoint ✓
5. challengeSuccess = (context.evaluation="deny" != taskResponse.evaluationResult="allow") → true
6. Honest operators slashed
Wait — step 4 has a subtlety. context.taskResponse == taskResponse comparison. If challenger passes the doctored taskResponse (with
altered policyTaskData.policy), then allTaskResponses won't match in _isChallengable. Let me verify the precondition check...
Read 1 file (ctrl+o to expand)
⏺ Important finding — I need to verify whether policyTaskData.policy is part of the BLS-signed struct. If it is, the signature
verification already binds the policy bytes and Gap 1 may not be exploitable.
Searched for 1 pattern (ctrl+o to expand)
⏺ I need to trace the _isChallengable check to confirm whether the binding actually holds.
Read 1 file (ctrl+o to expand)
⏺ Gap 1: Updated assessment (important — this partially changes my earlier call)
After tracing the full binding chain, I need to walk back the severity of Gap 1:
★ Insight ─────────────────────────────────────
- _isChallengable (line 317) binds keccak256(abi.encode(taskResponse, responseCertificate)) == allTaskResponses[taskId]. And
TaskResponse contains policyTaskData.policy (the raw Rego bytes, per NewtonMessage.sol:62). So the operator-signed bytes ARE
transitively locked via the BLS signature.
- In raiseAndResolveChallenge, a challenger cannot substitute different policy bytes into the SP1 input without breaking the
_isChallengable check (or the signature in slashForCrossChainChallenge). The proof evaluates the exact bytes operators signed.
- So Gap 1 does NOT enable wrongful slashing of honest operators. The remaining gap is evasion: colluding operators could sign a
TaskResponse containing policy bytes that differ from the canonical policyCid, and there's no on-chain check that pins
policyTaskData.policy to the IPFS CID. If those colluders evaluate their substitute code correctly, they escape slashing.
- I mis-flagged this as a blocker for wrongful slashing in the follow-up review. The right framing is: slashing-evasion via policy-code
substitution at signing time. Still worth fixing, but a different risk class from Critical 1–3.
─────────────────────────────────────────────────
Gap 1 still needs a fix because Octane labeled it Critical and the evasion vector is real, but the urgency is lower and the surface
area is larger. The fix requires:
1. Store keccak256(policyBytes) in NewtonPolicy at initialization (alongside policyCid), trust-on-first-use. Anyone can verify
off-chain that policyCid resolves to bytes matching the stored keccak256.
2. SP1 circuit change: compute keccak256(policy_bytes) inside the circuit and commit it as a public output in RegoContext.
3. Solidity RegoContext struct: add bytes32 policyCodeHash field.
4. Contract check in raiseAndResolveChallenge + slashForCrossChainChallenge: require(context.policyCodeHash ==
policy.getPolicyCodeHash(), PolicyCodeHashMismatch()).
5. Regenerate bindings: just generate-bindings after struct change.
6. Regenerate SP1 vkey: the circuit change invalidates the deployed verifier; requires redeploy of RegoVerifier.
Item 6 is a real operational cost — it means any currently-deployed RegoVerifier on testnet/mainnet needs redeployment as part of this
PR's rollout.
Gap 6: My recommendation — Option B (drop slashing, keep invalidation)
Here's the pros/cons analysis:
Option A: Add validateSignatoryRecord binding (mirror raiseAndResolveChallenge)
Pros:
- Preserves economic penalty for direct-path vs regular-path mismatches
- Consistent pattern with other challenge paths
Cons:
- Requires caller to pass responseCertificate → breaking API change on TaskManager.challengeDirectlyVerifiedMismatch
- Conceptually broken: we're inside the catch branch because verifyTaskResponse(task, taskResponse, signatureData) FAILED. Yet we
immediately decode NonSignerStakesAndSignature from the same signatureData that just failed verification. If the signature data is
invalid, we have no cryptographic basis to trust the non-signer set at all. Binding to responseCertificate.hashOfNonSigners helps, but
it ties slashing to a certificate whose signatures we already know don't verify against the supplied task/response — a weird
cryptographic posture.
- The slashing target is ambiguous: "operators who signed X but X's hash differs from the direct path's stored hash." Is this operator
misbehavior, or policy-client poisoning (Critical 1)? In Critical 1, the malicious actor is the policy client, not operators — slashing
operators here punishes the wrong party.
Option B: Drop slashing, only invalidate attestation
Pros:
- Closes the wrongful-slashing DoS vector entirely (over-slashing via truncated nonSignerPubkeys becomes impossible)
- Conceptually clean: detecting a mismatch between direct and regular paths is evidence that one of them is poisoned, but not evidence
of which, or by whom. Invalidating the attestation is the minimum necessary response — it prevents reliance on tainted state without
blaming operators who might be victims of Critical 1 (malicious policy client)
- API-compatible: no parameter changes, no new storage
- The try/catch can be simplified — we don't need it at all if we're not slashing. Just set taskSuccesfullyChallenged[taskId] = true on
mismatch and call invalidateAttestation
- Preserves the honest-slashing path via raiseAndResolveChallenge (which properly binds to responseCertificate and catches operator
misbehavior)
- Sidesteps the bare catch {} OOG vulnerability I raised in the initial review — if there's no slashing, there's nothing for a
permissionless attacker to force via OOG
Cons:
- Challenger loses slashing-bounty incentive for this specific path. But the challenger can still slash via raiseAndResolveChallenge or
slashForCrossChainChallenge if they have proof of actual operator misbehavior. And economically, detecting a direct/regular mismatch
is valuable as invalidation even without a bounty — the protocol benefits from not relying on poisoned state
Recommendation: Option B. The function's core value is detecting tainted state, not slashing. Slashing on a failed signature
verification is cryptographically unsound regardless of how we bind the non-signer set. Removing slashing here is a strict security
improvement and simplifies the code considerably.
---
Batch 2 — Gap 1 scope, Gap 6 confirmation, testing strategy
1. Gap 1 scope given the corrected severity: Do you want to proceed with the full fix (circuit + policy contract + bindings + verifier
redeploy) in THIS PR, or split it out into a follow-up PR and only address Gaps 2–9 here? The circuit+vkey change is a substantial
operational lift that could delay merging the wrongful-slashing fixes that ARE in Critical 1–3 class.
2. Gap 6 approval: Do you agree with Option B (drop slashing, only invalidate)? Or do you want me to go with Option A despite the
cryptographic weirdness, to preserve the slashing bounty?
3. Testing strategy: For regression tests (a blocker from the initial review), do you want me to (a) add Foundry tests for each of the
4 Criticals individually — one test per attack vector that MUST revert after the fix, OR (b) add broader integration tests that
exercise the full challenge flow, OR (c) both? Also: do you want Rust/operator-side tests for the new policyCodeHash propagation if we
go with full Gap 1 fix, or only Solidity tests?
✻ Sautéed for 4m 11s · 1 shell still running
※ recap: Implementing all fixes from our PR #541 review on branch fix/octane-critical-findings; waiting on your answers to Batch 2 (Gap 1
scope in-PR vs follow-up, Gap 6 Option B confirmation, testing strategy) before writing code.
❯ 1. full fix 2. help me understand this issue more clearly 3. (c)
⏺ Let me give you a complete walkthrough of Gap 6 so the decision is grounded in the actual control flow.
What challengeDirectlyVerifiedMismatch is supposed to do
The Newton TaskManager has two paths for recording operator attestations:
┌─────────┬────────────────────────────────────────┬────────────────────────────────────────────────┬──────────────────────────────┐
│ Path │ Entry │ Storage │ Purpose │
├─────────┼────────────────────────────────────────┼────────────────────────────────────────────────┼──────────────────────────────┤
│ │ respondToTask(task, taskResponse, │ allTaskResponses[taskId] = │ Standard BLS-aggregated │
│ Regular │ responseCertificate) │ keccak256(abi.encode(taskResponse, │ response submission │
│ │ │ responseCertificate)) │ │
├─────────┼────────────────────────────────────────┼────────────────────────────────────────────────┼──────────────────────────────┤
│ │ validateAttestationDirect(task, │ directTaskHashes[taskId], │ Optimistic path for │
│ Direct │ taskResponse, signatureData) │ directTaskResponseHashes[taskId] in │ consumers who want to │
│ │ │ AttestationValidator │ validate attestations inline │
└─────────┴────────────────────────────────────────┴────────────────────────────────────────────────┴──────────────────────────────┘
challengeDirectlyVerifiedMismatch exists to detect when these two paths disagree for the same taskId. If the direct path stored one
hash and the regular path stored a different one, something is wrong — either:
- Policy-client poisoning (Critical 1): A malicious policy client crafted a task whose TaskLib.taskHash(task) differs from the
on-chain-stored allTaskHashes[taskId]. They get it past the optimistic branch of validateAttestationDirect which stores
directTaskHashes[taskId] = TaskLib.taskHash(task) without binding it to the stored task hash.
- Operator misbehavior: Operators signed two different responses for the same taskId (equivocation). This shouldn't be possible in
honest operation — operators see the task once and sign once.
- Something else unexpected
Current flow after the PR (ChallengeVerifier.sol line 484–545)
// 1. Read both stored hashes
bytes32 directTaskHash = AttestationValidator(attestationValidator).directTaskHashes(taskId);
bytes32 directResponseHash = AttestationValidator(attestationValidator).directTaskResponseHashes(taskId);
// 2. Get the regular path's normalized response hash
bytes32 normalizedRegularResponseHash =
INewtonProverTaskManager(taskManager).normalizedTaskResponseHash(taskId);
// 3. Check EITHER task hash or response hash mismatches between paths
bool taskHashMismatch = directTaskHash != bytes32(0) && directTaskHash != regularTaskHash;
bool responseHashMismatch =
directResponseHash != bytes32(0) && directResponseHash != normalizedRegularResponseHash;
require(taskHashMismatch || responseHashMismatch, ChallengeFailed());
// 4. Try-verify signatures against caller-supplied (task, taskResponse, signatureData)
try ITaskResponseHandler(_taskResponseHandler)
.verifyTaskResponse(task, taskResponse, signatureData) returns (bytes32) {
revert ChallengeFailed(); // signatures verified → operators are fine → challenge invalid
} catch {
// verification failed → fall through to slashing
}
// 5. Slash based on caller-supplied signatureData
taskSuccesfullyChallenged[taskId] = true;
AttestationValidator(attestationValidator).invalidateAttestation(taskId);
if (blsApkRegistry != address(0)) {
IBLSSignatureCheckerTypes.NonSignerStakesAndSignature memory nonSignerStakesAndSignature =
abi.decode(signatureData, (IBLSSignatureCheckerTypes.NonSignerStakesAndSignature));
(bytes32[] memory hashesOfPubkeys, address[] memory addresses) =
ChallengeLib.processNonSigners(nonSignerStakesAndSignature.nonSignerPubkeys, blsApkRegistry);
ChallengeLib.slashSigningOperators(ctx, task.quorumNumbers, task.taskCreatedBlock, addresses);
}
The cryptographic incoherence in step 4-5
Step 4 says "if signatures verify → challenge fails → revert." So we only reach step 5 when signatures didn't verify. But step 5 then
decodes signatureData (the exact bytes that just failed verification) to extract the non-signer set, and slashes everyone not in that
set.
★ Insight ─────────────────────────────────────
- The moment verifyTaskResponse reverts, the contract has zero cryptographic evidence that signatureData is authentic. It could be
random bytes, it could be a mutated signature, it could be a deliberately-constructed payload designed to make the subsequent slashing
hit specific targets.
- Yet the slashing code trusts signatureData as the authoritative source for who the non-signers were — the same signatureData we just
proved we couldn't verify.
- raiseAndResolveChallenge escapes this by calling validateSignatoryRecord(taskCreatedBlock, hashes,
responseCertificate.hashOfNonSigners) before slashing, anchoring the non-signer set to the stored allTaskResponses hash. But there, the
signature check succeeded inside the proof evaluation — the sigData is known-authentic. Here, it's known-not-authentic.
─────────────────────────────────────────────────
Concrete attack scenario
Assume a legitimate direct/regular mismatch occurred (e.g., via Critical 1 poisoning before this PR's fix). An opportunistic attacker
wants to maximize damage — they want honest operators slashed, not just the attestation invalidated.
Step 1: Attacker observes a real mismatch and calls challengeDirectlyVerifiedMismatch(taskId, task, taskResponse, signatureData, ...).
Step 2: They craft signatureData as follows:
- Copy the valid NonSignerStakesAndSignature from the original respondToTask call (so processNonSigners works and slashSigningOperators
has a targetable list)
- Truncate or tamper the nonSignerPubkeys array: remove 3 honest operators who legitimately didn't sign (they're currently
"non-signers" and thus safe from slashing)
- Leave everything else intact
Step 3: The attacker might also corrupt a single byte in the signature field to guarantee verifyTaskResponse reverts (easy — any bit
flip in sigma breaks the BLS pairing).
Step 4: verifyTaskResponse reverts → catch block runs → slashing path executes.
Step 5: processNonSigners(nonSignerPubkeys, blsApkRegistry) returns addresses of the tampered (truncated) set. slashSigningOperators
slashes everyone in the operator set minus the truncated non-signer set — which now includes the 3 honest operators who didn't actually
sign.
Result: 3 honest operators lose 10% of their stake each. The attacker paid only gas. The direct/regular mismatch was real, but the
non-signer set they supplied was manipulated to maximize collateral damage.
What the raiseAndResolveChallenge pattern does (for comparison)
// In raiseAndResolveChallenge — the pattern I suggested as Option A
ChallengeLib.validateSignatoryRecord(
task.taskCreatedBlock,
hashesOfPubkeysOfNonSigningOperators, // from caller-supplied pubkeys
responseCertificate.hashOfNonSigners // from stored certificate — cryptographically trusted
);
This binds hashes (caller-controlled) to hashOfNonSigners (cryptographically authenticated via the BLS signature that successfully
verified the certificate). If the attacker tries to truncate the non-signer set, validateSignatoryRecord reverts because the recomputed
hash doesn't match the certificate-embedded one.
But this only works when signatures actually verified. In challengeDirectlyVerifiedMismatch's catch branch, they didn't. So we have no
hashOfNonSigners we can trust. Option A would need to require the caller to additionally pass the responseCertificate from the regular
path and re-verify keccak256(abi.encode(taskResponse, responseCertificate)) == allTaskResponses[taskId] before trusting its
hashOfNonSigners. That works structurally — but it changes the function signature, and the whole premise of the function is weaker than
raiseAndResolveChallenge because we still don't have proof of operator misbehavior (Critical 1 is policy client misbehavior).
Why Option B (drop slashing) is correct
Think about what a direct/regular mismatch actually proves:
┌──────────────────────────────────────┬─────────────────┬──────────────────────────────────────────────────────────────┐
│ Mismatch cause │ Who misbehaved? │ Should operators be slashed? │
├──────────────────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────┤
│ Policy-client poisoning (Critical 1) │ Policy client │ No — operators were victims │
├──────────────────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────┤
│ Operator equivocation │ Operators │ Yes — but raiseAndResolveChallenge catches this more cleanly │
├──────────────────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────┤
│ Storage corruption / bug │ Nobody │ No │
└──────────────────────────────────────┴─────────────────┴──────────────────────────────────────────────────────────────┘
In two of three cases, slashing is wrong or premature. The only case where slashing is justified is operator equivocation, and that's
detected by raiseAndResolveChallenge with proper binding.
The correct response to detecting a mismatch is:
1. Invalidate the tainted attestation — prevent downstream consumers from relying on it
2. Mark the task as challenged — close the challenge window
3. Let a follow-up raiseAndResolveChallenge do actual slashing if operators misbehaved
Option B reduces the function to exactly that:
function challengeDirectlyVerifiedMismatch(
bytes32 taskId,
bytes32 regularTaskHash
) external onlyTaskManager nonReentrant {
require(isChallengeEnabled, ChallengeNotEnabled());
require(!taskSuccesfullyChallenged[taskId], NotChallengable());
require(
AttestationValidator(attestationValidator).isDirectlyVerified(taskId),
TaskManagerErrors.NotDirectlyVerified()
);
bytes32 directTaskHash = AttestationValidator(attestationValidator).directTaskHashes(taskId);
bytes32 directResponseHash =
AttestationValidator(attestationValidator).directTaskResponseHashes(taskId);
bytes32 normalizedRegularResponseHash =
INewtonProverTaskManager(taskManager).normalizedTaskResponseHash(taskId);
require(normalizedRegularResponseHash != bytes32(0), NotChallengable());
bool taskHashMismatch = directTaskHash != bytes32(0) && directTaskHash != regularTaskHash;
bool responseHashMismatch =
directResponseHash != bytes32(0) && directResponseHash != normalizedRegularResponseHash;
require(taskHashMismatch || responseHashMismatch, ChallengeFailed());
// Record challenge success and invalidate the tainted attestation.
// Slashing is NOT performed here — a mismatch proves state is tainted, not who tainted it.
// Operators who genuinely misbehaved should be slashed via raiseAndResolveChallenge.
taskSuccesfullyChallenged[taskId] = true;
AttestationValidator(attestationValidator).invalidateAttestation(taskId);
}
This also removes the need for the try/catch entirely, which sidesteps the bare-catch {} OOG vulnerability I flagged in the initial
review.
What we lose
- The function no longer pays a slashing bounty to the challenger. But the challenger still wins: they prevented state from being
relied upon, and if there's underlying operator misbehavior, they can call raiseAndResolveChallenge separately to claim the slashing
bounty properly.
- If operators equivocated (signed two different responses for the same taskId) AND the regular path's signature is still valid,
raiseAndResolveChallenge handles the slashing correctly. The direct-path side doesn't need its own slashing logic.
What we gain
1. No more wrongful-slashing vector via manipulated signatureData
2. No more bare catch {} OOG attack surface — the whole try/catch disappears
3. Simpler function: removes ~45 lines of broken slashing logic
4. Conceptual coherence: "detect taint, invalidate, don't blame"
5. Fewer parameters: caller no longer needs task, taskResponse, signatureData, _taskResponseHandler — just taskId and the stored
regular hash
▐▛███▜▌ Claude Code v2.1.109
▝▜█████▛▘ Opus 4.6 (1M context) with medium effort · Claude API
▘▘ ▝▝ ~/projects/newton-prover-avs
✻ Conversation compacted (ctrl+o for history)
❯ /compact
⎿ Compacted (ctrl+o to see full summary)
PreCompact [~/.claude/hooks/save-compact-state.sh] completed successfully
PreCompact [node "${CLAUDE_PLUGIN_ROOT}/scripts/pre-compact.mjs"] completed successfully: {"continue":true,"systemMessage":"#
PreCompact Checkpoint\n\nCreated: 2026-04-15T21:17:07.047Z\nTrigger: manual\n\n## Active Modes\n\n- Ecomode\n Prompt: this is
really important: we should NOT guess what...\n\n---\nNote: This checkpoint preserves critical state before compaction.\nReview
active modes to ensure continuity after compaction."}
PreCompact [node "${CLAUDE_PLUGIN_ROOT}/scripts/project-memory-precompact.mjs"] completed successfully: {"continue":true}
⎿  Read ../../../../tmp/av.sol (363 lines)
⎿  Read contracts/src/interfaces/CLAUDE.md (7 lines)
⎿  Referenced file ../../../../tmp/cv.sol
⎿  Read circuits/sp1-rego/src/main.rs (106 lines)
⎿  Read contracts/src/interfaces/IRegoVerifier.sol (30 lines)
⎿  Skills restored (clarify-plan, prover-communication-style, review-remote-pr)
❯ continue
⏺ I have full context. Let me present the implementation plan for your approval before writing code.
Implementation Plan: PR #541 Critical Findings Resolution
Phase 1: Critical 1 — Strengthen validateAttestationDirect optimistic branch
Problem: BLS consensus digest covers only taskResponse, not task. Fields in task that don't overlap with taskResponse
(taskCreatedBlock, wasmArgs, quorumNumbers, quorumThresholdPercentage, initializationTimestamp) are unbound — a malicious policy client
can commit directTaskHashes[taskId] = TaskLib.taskHash(task) over arbitrary values.
Fix: In AttestationValidator.validateAttestationDirect optimistic branch (av.sol:239-278), bind every overlapping field between task
and taskResponse explicitly:
require(task.taskId == taskResponse.taskId, TaskLib.InvalidTaskId());
require(task.policyClient == taskResponse.policyClient, TaskLib.InvalidPolicyClient());
require(
keccak256(abi.encode(task.intent)) == keccak256(abi.encode(taskResponse.intent)),
TaskLib.InvalidIntent()
);
require(
keccak256(task.intentSignature) == keccak256(taskResponse.intentSignature),
TaskLib.InvalidIntentSignature()
);
Non-overlapping fields (taskCreatedBlock, wasmArgs, quorumNumbers, quorumThresholdPercentage, initializationTimestamp) remain
caller-controlled in the optimistic branch. This is acceptable because:
- taskSuccesfullyChallenged path uses directTaskHashes[taskId] only for dedup, not slashing binding
- The challenge paths (raiseAndResolveChallenge, slashForCrossChainChallenge) use the RegoContext's task which comes from the SP1
proof, not directTaskHashes
- A malicious taskCreatedBlock etc. in the optimistic branch only affects attestation lifetime, not slashing correctness
Phase 2: Critical 4 Gap 1 — Bind policy code hash into SP1 proof
Files to modify:
1. circuits/sp1-rego/src/main.rs: Compute policy_code_hash = keccak256(task_response.policyTaskData.policy) and add to committed
RegoContext
2. contracts/src/interfaces/IRegoVerifier.sol: Add bytes32 policyCodeHash to RegoContext struct
3. contracts/src/core/NewtonPolicy.sol: Add bytes32 public policyCodeHash, constructor param, getPolicyCodeHash() getter
4. contracts/src/interfaces/INewtonPolicy.sol: Add getPolicyCodeHash() interface method
5. contracts/src/core/NewtonPolicyFactory.sol: Thread _policyCodeHash through all deployPolicy* overloads
6. contracts/src/middlewares/ChallengeVerifier.sol:
- In raiseAndResolveChallenge (~line 162): add require(context.policyCodeHash == INewtonPolicy(policyAddress).getPolicyCodeHash(),
PolicyCodeHashMismatch())
- In slashForCrossChainChallenge (~line 251): same check
7. Regenerate SP1 verification key (invalidates deployed RegoVerifier — operational redeploy required)
8. Run just generate-bindings
Phase 3: Critical 4 Gap 6 — Drop slashing from challengeDirectlyVerifiedMismatch (Option B)
contracts/src/middlewares/ChallengeVerifier.sol challengeDirectlyVerifiedMismatch:
- Remove task, taskResponse, signatureData, _taskResponseHandler parameters
- Remove try/catch verifyTaskResponse + slashing block (cv.sol:506-513)
- Remove abi.decode(signatureData, (NonSignerStakesAndSignature)) slashing block (cv.sol:521-544)
- Keep only: mismatch detection between directTaskHashes[taskId] / directTaskResponseHashes[taskId] and allTaskHashes[taskId] /
normalizedTaskResponseHash(taskId), then taskSuccesfullyChallenged[taskId] = true + AttestationValidator.invalidateAttestation(taskId)
contracts/src/core/NewtonProverTaskManagerShared.sol (or wherever TaskManager calls ChallengeVerifier): update caller signature to
match.
Phase 4: Housekeeping (Gaps 7, 8, 9)
- TaskManagerStorage.sol: Add NatSpec on allTaskResponses pointing to allNormalizedTaskResponses (Gap 8)
- INewtonProverTaskManager.sol: Add NatSpec on normalizedTaskResponseHash getter
- ChallengeVerifier.sol: Fix dual // 7. numbering
- Storage layout baseline: just snapshot-storage-layouts
Phase 5: Tests (Option c — per-Critical + integration + Rust)
Foundry unit tests (one per Critical, each proving attack MUST revert):
1. test_Critical1_directPathRejectsTaskResponseMismatch — craft task+taskResponse with diverging policyClient, expect revert
2. test_Critical1_directPathRejectsIntentMismatch — diverging intent, expect revert
3. test_Critical4_challengeRejectsWrongPolicyCodeHash — submit proof with policyCodeHash != policy's stored hash, expect
PolicyCodeHashMismatch
4. test_Critical4_mismatchChallengeDoesNotSlash — trigger mismatch, assert only invalidateAttestation + taskSuccesfullyChallenged, no
slashing call
Integration tests: full challenge flow with SP1 proof, both raiseAndResolveChallenge and slashForCrossChainChallenge.
Rust tests: assert RegoContext bindings expose policyCodeHash, assert SP1 circuit computes it deterministically from
task_response.policyTaskData.policy.
---
★ Insight ─────────────────────────────────────
- The consensus digest scope is the root of Critical 1. TaskLib.computeConsensusDigest hashing only taskResponse (not task) means the
task struct is a trust boundary that only the on-chain allTaskHashes[taskId] can authoritatively resolve — never caller-supplied task
calldata in isolation.
- Gap 1 evasion class vs wrongful-slashing class matters for severity framing. Because policyTaskData.policy is transitively BLS-signed
via TaskResponse, an attacker cannot substitute policy bytes to frame honest operators — they can only collude to escape slashing by
claiming the proof used "different" policy code. That still breaks the soundness of the challenge mechanism but doesn't create new loss
paths.
- Gap 6's "Option B" (drop slashing) is the cryptographically coherent choice because the failed verifyTaskResponse means we cannot
trust any signer set derived from signatureData — decoding it anyway to slash is using unverified input as ground truth.
─────────────────────────────────────────────────
Operational note
Phase 2 requires redeploying RegoVerifier with a new SP1 verification key. This is a hard dependency — the on-chain
RegoVerifier.verifyRegoProof will revert on old proofs until the new vkey is live. Coordinate with the deploy flow before merging.
Open questions before I start
1. Critical 1 Phase 1: Do you want me to also add keccak256(task.wasmArgs) == keccak256(taskResponse.wasmArgs) check? taskResponse has
intent/intentSignature but I need to grep whether it carries wasmArgs — if it does, this is a free bind; if not, we leave it
caller-controlled (acceptable per rationale above).
2. Phase 2 ordering: Should I do SP1 circuit + contracts in one commit, or separate so binding regeneration is isolated?
3. Phase 5 tests: Do you want tests co-located with existing Foundry tests in contracts/test/, or a new contracts/test/octane-findings/
subdirectory?
Confirm the plan (or flag changes) and answer the three open questions, then I'll begin implementation starting with Phase 1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment