Skip to content

Instantly share code, notes, and snippets.

@denniswon
Created March 15, 2026 19:36
Show Gist options
  • Select an option

  • Save denniswon/1b9e6911431ef9d49f67ec84becf32df to your computer and use it in GitHub Desktop.

Select an option

Save denniswon/1b9e6911431ef9d49f67ec84becf32df to your computer and use it in GitHub Desktop.
Security Report: Cross-Chain Challenge Slashing Vulnerability
Security Report: Cross-Chain Challenge Slashing Vulnerability
Executive Summary
A critical vulnerability exists in the slashForCrossChainChallenge function of ChallengeVerifier.sol that allows arbitrary slashing of operators on the source chain. The function fails to bind the ZK proof's public values to the caller-supplied inputs and accepts an unverified non-signer list, enabling attackers to slash any chosen operator set without legitimate grounds.
Vulnerability Details
Location
File: ChallengeVerifier.sol
Function: slashForCrossChainChallenge (Lines 190-259)
Accessible via: NewtonProverTaskManagerShared.slashForCrossChainChallenge (Lines 160-189)
Root Cause Analysis
The vulnerability stems from three distinct missing validations:
1. No Binding Between Proof and Inputs
After verifying the SP1 proof, the contract extracts context but never validates that it matches the caller-supplied parameters:
solidity
IRegoVerifier.RegoContext memory context =
RegoVerifier(regoVerifier).verifyRegoProof(challenge.data, challenge.proof);
// Verify proof output mismatches the task response (challenge is valid)
bool challengeSuccess = keccak256(abi.encode(context.evaluation))
!= keccak256(abi.encode(taskResponse.evaluationResult));
require(challengeSuccess, ChallengeFailed());
The code only checks that context.evaluation != taskResponse.evaluationResult. It does not verify:
context.task == task
context.taskResponse == taskResponse
context.entrypoint == policy.getEntrypoint()
An attacker can submit a valid proof generated for an entirely different task/response pair, as long as the evaluation values differ.
2. No Operator Signature/Certificate Verification
The function accepts pubkeysOfNonSigningOperators directly from the caller without any verification:
solidity
(, address[] memory addressOfNonSigningOperators) =
ChallengeLib.processNonSigners(pubkeysOfNonSigningOperators, blsApkRegistry);
Unlike raiseAndResolveChallenge, which validates non-signers against responseCertificate.hashOfNonSigners:
solidity
// In raiseAndResolveChallenge (correct implementation):
ChallengeLib.validateSignatoryRecord(
task.taskCreatedBlock,
hashesOfPubkeysOfNonSigningOperators,
responseCertificate.hashOfNonSigners
);
The cross-chain function has no equivalent validation, allowing attackers to supply arbitrary pubkeys or an empty list.
3. Attacker Controls Slashing Parameters
The task parameter is entirely attacker-controlled:
solidity
ChallengeLib.slashSigningOperators(
ctx, task.quorumNumbers, task.taskCreatedBlock, addressOfNonSigningOperators
);
The attacker chooses:
task.quorumNumbers — which operator sets to slash
task.taskCreatedBlock — which historical block to reference for operator membership
addressOfNonSigningOperators — who to exclude from slashing
Attack Flow
Step-by-Step Exploitation
Attacker identifies target operators
Select any operator set registered in registryCoordinator
Choose a taskCreatedBlock when target operators were active
Attacker crafts malicious inputs
solidity
Task memory fakeTask = Task({
taskId: bytes32(0x1234...),
taskCreatedBlock: TARGET_BLOCK,
quorumNumbers: TARGET_QUORUM_BYTES,
// ... other fields are arbitrary
});
TaskResponse memory fakeResponse = TaskResponse({
evaluationResult: bytes("RESULT_A"),
// ... other fields point to any verified policy
});
Attacker generates valid SP1 proof
Create a legitimate proof for any unrelated context
Ensure context.evaluation differs from fakeResponse.evaluationResult
The proof is mathematically valid but semantically disconnected
Attacker submits empty non-signer list
pubkeysOfNonSigningOperators = []
All operators in the target set become "signers" eligible for slashing
Attacker calls the function
solidity
taskManager.slashForCrossChainChallenge(
registeredDestChainId,
fakeTask,
fakeResponse,
validProofForWrongContext,
[] // empty non-signer list
);
All target operators are slashed 10%
Slashing Execution Path
The slashing occurs in ChallengeLib.slashSigningOperators:
solidity
// For each operator NOT in non-signer list:
IAllocationManager(ctx.allocationManager).slashOperator(
ctx.serviceManager,
IAllocationManagerTypes.SlashingParams({
operator: operators[i],
operatorSetId: quorumNumber,
strategies: strategies,
wadsToSlash: wadsToSlash, // 10% = 1e17
description: "..."
})
);
Exploitation Scenarios
Scenario 1: Mass Operator Slashing
Objective: Slash all operators in a target quorum
Preconditions:
isChallengeEnabled == true
serviceManager != address(0) (source chain)
At least one registered destination chain
A verified policy exists on-chain
Target operator set exists at chosen block
ChallengeVerifier authorized to request slashing
Execution:
Craft Task with target quorumNumbers and taskCreatedBlock
Craft TaskResponse pointing to verified policy with arbitrary evaluationResult
Generate SP1 proof with different evaluation value
Call with empty pubkeysOfNonSigningOperators
Result: 100% of operators in target quorum slashed 10%
Scenario 2: Selective/Targeted Slashing
Objective: Slash specific operators while protecting others
Preconditions:
All Scenario 1 preconditions
Knowledge of BLS pubkeys for operators to protect
Execution:
Same as Scenario 1, but include friendly operators in pubkeysOfNonSigningOperators
Only excluded operators are slashed
Result: Discriminatory slashing of chosen operators
Scenario 3: Repeated Slashing Attacks
Objective: Drain operator stakes through multiple slashes
Preconditions:
All Scenario 1 preconditions
Slashing system permits multiple events
Execution:
Execute Scenario 1
Vary task or taskResponse fields to create new crossChainKey:
solidity
bytes32 crossChainKey = keccak256(abi.encode(taskHash, responseHash));
Repeat with modified inputs to bypass replay protection
Result: Cumulative slashing (10% each time) until stakes depleted
Comparison with Secure Implementation
The raiseAndResolveChallenge function (same-chain path) demonstrates correct validation:
solidity
// 1. Binds proof to stored task hash
require(
TaskLib.taskHash(context.task) == allTaskHashes[taskResponse.taskId],
TaskLib.TaskMismatch(...)
);
// 2. Binds proof to stored response hash
require(
keccak256(abi.encode(context.taskResponse)) == allTaskResponses[taskResponse.taskId],
TaskLib.TaskResponseMismatch()
);
// 3. Binds proof to policy entrypoint
require(
keccak256(abi.encode(policy.getEntrypoint()))
== keccak256(abi.encode(context.entrypoint)),
TaskLib.EntrypointMismatch()
);
// 4. Validates non-signer list against certificate
ChallengeLib.validateSignatoryRecord(
task.taskCreatedBlock,
hashesOfPubkeysOfNonSigningOperators,
responseCertificate.hashOfNonSigners
);
The cross-chain function lacks all four of these protections.
Recommended Remediation
Primary Fixes Required
1. Bind SP1 Proof Outputs to Inputs
Add validation that proof public values match caller-supplied data:
solidity
// After verifyRegoProof:
require(
keccak256(abi.encode(context.task)) == keccak256(abi.encode(task)),
TaskLib.TaskMismatch(...)
);
require(
keccak256(abi.encode(context.taskResponse)) == keccak256(abi.encode(taskResponse)),
TaskLib.TaskResponseMismatch()
);
require(
keccak256(abi.encode(context.entrypoint))
== keccak256(abi.encode(policy.getEntrypoint())),
TaskLib.EntrypointMismatch()
);
2. Require and Verify BN254 Certificate
Add a certificate parameter and verify operator signatures on source chain:
solidity
function slashForCrossChainChallenge(
uint256 destChainId,
INewtonProverTaskManager.Task calldata task,
INewtonProverTaskManager.TaskResponse calldata taskResponse,
INewtonProverTaskManager.ResponseCertificate calldata responseCertificate, // NEW
INewtonProverTaskManager.ChallengeData calldata challenge,
IBLSSignatureCheckerTypes.NonSignerStakesAndSignature calldata nonSignerData // MODIFIED
) external onlyTaskManager nonReentrant returns (bool) {
// Verify BLS aggregate signature on source chain
_verifyBLSSignature(task, taskResponse, responseCertificate, nonSignerData);
// Derive non-signers from verified certificate
ChallengeLib.validateSignatoryRecord(
task.taskCreatedBlock,
_hashPubkeys(nonSignerData.nonSignerPubkeys),
responseCertificate.hashOfNonSigners
);
// ... rest of function
}
3. Validate Task/Response Existence on Destination Chain
Consider requiring proof that the task was actually created on the destination chain:
solidity
// Option A: Include destination chain task hash in proof
require(
context.destinationChainTaskHash == expectedDestChainTaskHash,
"Task not registered on destination"
);
// Option B: Bridge verification (if available)
require(
destChainBridge.verifyTaskExists(destChainId, taskId),
"Task not verified via bridge"
);
Open Questions for Review
1. SP1 Proof Circuit Design
Question: Does the SP1 circuit's public values structure support the required bindings?
The fix assumes context.task, context.taskResponse, and context.entrypoint are exposed as public values. If the circuit design differs, modifications to the proving system may be required.
2. Cross-Chain Certificate Verification
Question: Can BLS signatures be verified on the source chain for tasks created on destination chains?
This depends on:
Whether operator sets are synchronized across chains
Whether the blsApkRegistry on source chain contains destination chain operators
The intended cross-chain trust model
3. Destination Chain Task Registration
Question: How should the source chain verify a task legitimately existed on the destination chain?
Options include:
Bridge-based verification (requires trusted bridge)
ZK proof of destination chain state (complex)
Operator-attested existence (circular trust)
4. Replay Key Robustness
Question: Is content-addressed keying sufficient for replay protection?
Current implementation:
solidity
bytes32 crossChainKey = keccak256(abi.encode(taskHash, responseHash));
Consider whether including destChainId in the key is necessary after proper validation is added.
Impact Assessment
Impact Category Severity Description
Financial Loss Critical Direct loss of operator stakes (10% per slash, repeatable)
Protocol Integrity Critical Slashing system weaponized against honest operators
Operator Trust Critical Operators may withdraw due to arbitrary slashing risk
Attack Surface High Publicly accessible function, no privileged access required
Exploitation Complexity Medium Requires SP1 proof generation but no on-chain state manipulation
Conclusion
The slashForCrossChainChallenge function contains a critical vulnerability that bypasses the security guarantees of the challenge system. Immediate remediation is required before production deployment. The function should either be disabled entirely or patched with the validation logic present in raiseAndResolveChallenge, adapted for the cross-chain context.
@denniswon

Copy link
Copy Markdown
Author

★ Insight ─────────────────────────────────────
The key difference between the secure raiseAndResolveChallenge (line 118) and the vulnerable slashForCrossChainChallenge (line 208) in
ChallengeVerifier.sol reveals exactly three missing validation layers. Understanding the contrast is the fastest way to grasp the fix.
─────────────────────────────────────────────────

Vulnerability Analysis: slashForCrossChainChallenge

Side-by-Side: Secure vs Vulnerable Path

Comparing raiseAndResolveChallenge (lines 118-197) with slashForCrossChainChallenge (lines 208-263):

┌─────────────────────┬────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────┐
│ Validation │ raiseAndResolveChallenge │ slashForCrossChainChallenge │
├─────────────────────┼────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ Proof↔Task binding │ context.task hash == allTaskHashes[taskId] (line 148) │ MISSING — proof's context.task is never checked │
│ │ │ against caller's task │
├─────────────────────┼────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ Proof↔Response │ context.taskResponse == allTaskResponses[taskId] (line 152) │ MISSING — proof's context.taskResponse is never │
│ binding │ │ checked against caller's taskResponse │
├─────────────────────┼────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ Proof↔Entrypoint │ context.entrypoint == policy.getEntrypoint() (line 156) │ MISSING — proof's context.entrypoint is never │
│ binding │ │ checked │
├─────────────────────┼────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ Non-signer │ validateSignatoryRecord() checks non-signers match │ MISSING — non-signer list is caller-supplied with │
│ validation │ responseCertificate.hashOfNonSigners (line 172) │ zero validation │
├─────────────────────┼────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ Task existence │ allTaskHashes[taskId] must be non-zero │ N/A (cross-chain tasks don't exist on source — this │
│ │ │ is by design) │
└─────────────────────┴────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────┘

The Three Attack Vectors

  1. Proof-Input Disconnection (lines 235-241)

// The proof is verified cryptographically...
IRegoVerifier.RegoContext memory context =
RegoVerifier(regoVerifier).verifyRegoProof(challenge.data, challenge.proof);

// ...but only context.evaluation is checked against taskResponse
bool challengeSuccess = keccak256(abi.encode(context.evaluation))
!= keccak256(abi.encode(taskResponse.evaluationResult));

The SP1 proof's RegoContext contains task, taskResponse, and entrypoint as public values — but none are compared to the caller-supplied task and
taskResponse parameters. An attacker generates a valid proof for any unrelated task/policy pair where evaluation differs from the caller-supplied
taskResponse.evaluationResult, then passes attacker-crafted task/taskResponse as the slashing target.

  1. Unverified Non-Signer List (lines 244-245)

(, address[] memory addressOfNonSigningOperators) =
ChallengeLib.processNonSigners(pubkeysOfNonSigningOperators, blsApkRegistry);

In raiseAndResolveChallenge, the non-signer list is validated against responseCertificate.hashOfNonSigners (line 172-176) — proving these
operators actually didn't sign. In the cross-chain path, there's no ResponseCertificate parameter at all. The caller supplies arbitrary BLS G1
points, processNonSigners resolves them to addresses via BLSApkRegistry, and everyone not in that list gets slashed. The attacker controls who
gets slashed by choosing which operators to include or exclude.

  1. Attacker-Controlled Slashing Parameters (lines 256-258)

ChallengeLib.slashSigningOperators(
ctx, task.quorumNumbers, task.taskCreatedBlock, addressOfNonSigningOperators
);

The task struct is entirely attacker-supplied. task.quorumNumbers picks which quorums to slash, and task.taskCreatedBlock picks which historical
operator set snapshot to use. Combined with vector #2, the attacker has full control over which operators, which quorums, and at what point in
time.

  1. Public Access (no access control)

In NewtonProverTaskManagerShared.sol line 163-179, slashForCrossChainChallenge has only the whenNotPaused modifier — no onlyTaskGenerator or
onlyTaskManager restriction. Any EOA can call it.

Exploitation Flow

Attacker crafts:
task = { quorumNumbers: [target quorum], taskCreatedBlock: [block with target operators] }
taskResponse = { evaluationResult: 0x01, policyTaskData.policyAddress: [any verified policy] }
challenge = { proof: [valid SP1 proof for UNRELATED context where evaluation=0x00] }
pubkeysOfNonSigningOperators = [] (empty → ALL operators are "signers" → ALL slashed)

→ ChallengeVerifier verifies proof ✓ (proof IS valid, just unrelated)
→ context.evaluation (0x00) != taskResponse.evaluationResult (0x01) ✓
→ Empty non-signer list → every operator in quorum treated as signer
→ slashSigningOperators slashes ALL operators by 10%
→ Repeat with varied task/response fields to bypass replay protection

Required Fix

The fix needs to add the three bindings that raiseAndResolveChallenge already has, plus certificate-based non-signer validation:

// After verifyRegoProof (line 236), ADD:

// 1. Bind proof's task to caller-supplied task
require(
keccak256(abi.encode(context.task)) == keccak256(abi.encode(task)),
TaskLib.TaskMismatch(keccak256(abi.encode(task)), keccak256(abi.encode(context.task)))
);

// 2. Bind proof's taskResponse to caller-supplied taskResponse
require(
keccak256(abi.encode(context.taskResponse)) == keccak256(abi.encode(taskResponse)),
TaskLib.TaskResponseMismatch()
);

// 3. Bind proof's entrypoint to the on-chain policy's entrypoint
require(
keccak256(abi.encode(policy.getEntrypoint()))
== keccak256(abi.encode(context.entrypoint)),
TaskLib.EntrypointMismatch()
);

// 4. Require a BN254 certificate and derive non-signers from it
// (instead of trusting caller-supplied pubkeysOfNonSigningOperators)

The fourth fix (certificate verification) is the hardest — it requires adding a ResponseCertificate or BN254Certificate parameter and verifying
BLS signatures on the source chain to cryptographically prove which operators actually signed the response on the destination chain. Without this,
the non-signer list remains attacker-controlled even with the proof bindings fixed.

★ Insight ─────────────────────────────────────

  • The root cause is a common pattern in cross-chain designs: the secure same-chain path has on-chain state to verify against (allTaskHashes,
    allTaskResponses, responseCertificate.hashOfNonSigners), but the cross-chain path deliberately skips those checks because dest-chain tasks don't
    exist on source. The security model assumed "re-verifying the ZK proof is sufficient" — but the proof only binds its own public values, not the
    caller-supplied slashing parameters.
  • Fixes 1-3 are straightforward require additions mirroring raiseAndResolveChallenge. Fix 4 (certificate verification) requires an architectural
    decision: either require a BN254 certificate from the destination chain and verify it on source, or use a bridge/oracle to attest the non-signer
    set.
    ─────────────────────────────────────────────────

Immediate Mitigation

Until the full fix is deployed, the safest mitigation is to not register any destination chains (registeredDestinationChains stays empty) or
disable challenges entirely (isChallengeEnabled = false). The require(registeredDestinationChains[destChainId]) guard on line 217 blocks the
entire path if no chains are registered.

@denniswon

Copy link
Copy Markdown
Author

Summary of Changes

★ Insight ─────────────────────────────────────
The fix mirrors the exact validation pattern already used by the secure raiseAndResolveChallenge function. The key architectural lesson: when a
function can't verify against on-chain state (cross-chain tasks don't exist on source), it must verify against the ZK proof's own public values
AND require cryptographic proof of the non-signer set. Both verification layers are necessary — proof binding alone prevents proof replay but not
non-signer manipulation, and certificate verification alone prevents non-signer manipulation but not proof replay.
─────────────────────────────────────────────────

Files Modified (4 files)

┌───────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────┐
│ File │ Change │
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┤
│ contracts/src/interfaces/INewtonProverTaskManager.sol │ Added bytes calldata signatureData parameter │
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┤
│ contracts/src/NewtonProverTaskManagerShared.sol │ Threads signatureData + taskResponseHandler to ChallengeVerifier │
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┤
│ contracts/src/middlewares/ChallengeVerifier.sol │ Core fix: 4 new validation layers in slashForCrossChainChallenge │
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┤
│ crates/challenger/src/dest/relayer.rs │ Passes response_certificate.signatureData in relay call │
└───────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────┘

Vulnerability Closures

┌────────────────────────────────┬───────────────────────────────────────────────────────┬───────────────────────────┐
│ Vector │ Fix │ Line │
├────────────────────────────────┼───────────────────────────────────────────────────────┼───────────────────────────┤
│ Proof-Task disconnection │ require(context.task == task) │ ChallengeVerifier:246-249 │
├────────────────────────────────┼───────────────────────────────────────────────────────┼───────────────────────────┤
│ Proof-Response disconnection │ require(context.taskResponse == taskResponse) │ ChallengeVerifier:250-253 │
├────────────────────────────────┼───────────────────────────────────────────────────────┼───────────────────────────┤
│ Proof-Entrypoint disconnection │ require(policy.getEntrypoint() == context.entrypoint) │ ChallengeVerifier:254-258 │
├────────────────────────────────┼───────────────────────────────────────────────────────┼───────────────────────────┤
│ Unverified non-signer list │ verifyTaskResponse() → validateSignatoryRecord() │ ChallengeVerifier:268-281 │
└────────────────────────────────┴───────────────────────────────────────────────────────┴───────────────────────────┘

Next Steps

  1. Run make generate-bindings to regenerate Rust ABI bindings (the Rust relayer won't compile until this is done)
  2. Run make clippy after bindings regeneration to verify Rust compiles cleanly
  3. Run challenger E2E (make challenger-e2e) to verify the relay path end-to-end
  4. ABI-breaking change notice: This changes the slashForCrossChainChallenge selector (new signatureData param). Any external callers need to
    recompile against the new ABI — same pattern as the initializationTimestamp selector mismatch documented in lessons.md

@denniswon

Copy link
Copy Markdown
Author

★ Insight ─────────────────────────────────────

The current NewtonVault treats Newton as a centralized signer — it stores an attestor address and does ECDSA.recover() against it. This
is fundamentally different from what Newton Protocol actually provides.

What Newton Protocol actually does: Operators stake ETH via EigenLayer, evaluate Rego policies in WASM, BLS-sign their approvals, and the
aggregator produces a quorum-backed attestation that can be verified on-chain via NewtonProverTaskManager. The NewtonPolicyClient mixin
is the standard way to consume this.

The gap: The current vault's settle() function verifies ECDSA.recover(digest, attestorSignature) == attestor — this trusts a single key.
The Newton model verifies BLS quorum via _validateAttestation() or _validateAttestationDirect() — this trusts the operator set's economic
stake.

─────────────────────────────────────────────────

Current State vs Required State

┌───────────────┬───────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────┐
│ Aspect │ Current (NewtonVault) │ Required (Newton PolicyClient) │
├───────────────┼───────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ Attestation │ Single ECDSA key (attestor) │ BLS quorum via NewtonProverTaskManager │
├───────────────┼───────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ Inheritance │ Initializable, OwnableUpgradeable, │ + NewtonPolicyClient │
│ │ PausableUpgradeable │ │
├───────────────┼───────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ Verification │ ECDSA.recover() == attestor │ _validateAttestation() or _validateAttestationDirect() │
├───────────────┼───────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ Policy │ Local AgentPolicy struct (daily limit, │ Newton INewtonPolicy.PolicyConfig (Rego-evaluated) + local budget │
│ │ allowlist) │ enforcement │
├───────────────┼───────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ Dependency │ OZ only │ OZ + newton-prover-avs/contracts/ (private repo) │
├───────────────┼───────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ Security │ Trust gateway key holder │ Trust BLS-staked operator quorum (slashable) │
│ model │ │ │
└───────────────┴───────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────┘

How to Resolve the Private Dependency

Based on your direction to use git submodule with SSH, here's what's needed:

  1. Add newton-prover-avs as a Forge submodule

From newton-agent-neobank root

forge install git@github.com:newt-foundation/newton-prover-avs --no-commit

This pulls the full repo into lib/newton-prover-avs/. But newton-prover-avs itself has deep transitive dependencies (EigenLayer
middleware, BN254 crypto, risc0, sp1-contracts), so we'd actually need:

  1. Remappings in foundry.toml

remappings = [
# Existing
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/",
"@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/",
"forge-std/=lib/forge-std/src/",
# Newton protocol contracts
"newton-contracts/=lib/newton-prover-avs/contracts/src/",
# Newton's transitive deps (EigenLayer, etc.)
"@eigenlayer/=lib/newton-prover-avs/contracts/lib/eigenlayer-middleware/lib/eigenlayer-contracts/src/",
"@eigenlayer-middleware/=lib/newton-prover-avs/contracts/lib/eigenlayer-middleware/",
"@openzeppelin-upgrades/=lib/newton-prover-avs/contracts/lib/eigenlayer-middleware/lib/openzeppelin-contracts-upgradeable/",
]

  1. Critical: via_ir = true

The Newton docs explicitly state this is required:

[profile.default]
via_ir = true

  1. The Vault Refactor

The settle() function would change from:

// CURRENT: centralized ECDSA check
address recoveredAttestor = ECDSA.recover(digest, attestorSignature);
require(recoveredAttestor == attestor, InvalidAttestorSignature());

To one of two patterns:

Pattern A — Standard Attestation (aggregator has already called respondToTask):
function settle(
SettlementRequest calldata request,
NewtonMessage.Attestation calldata attestation,
bytes calldata agentSignature
) external whenNotPaused {
// Newton BLS-attested policy verification
require(_validateAttestation(attestation), InvalidAttestation());

  // Agent EIP-712 signature (unchanged)
  // ... budget enforcement (unchanged)
  // ... token transfer (unchanged)

}

Pattern B — Direct Attestation (no aggregator wait, verify BLS on-chain):
function settle(
SettlementRequest calldata request,
INewtonProverTaskManager.Task calldata task,
INewtonProverTaskManager.TaskResponse calldata taskResponse,
bytes calldata signatureData,
bytes calldata agentSignature
) external whenNotPaused {
// Direct BLS verification on-chain
require(
_validateAttestationDirect(task, taskResponse, signatureData),
InvalidAttestation()
);
// ... rest unchanged
}

  1. Initialization changes

function initialize(
address _owner,
address _policyTaskManager, // replaces _attestor
address _policy // Newton policy contract
) public initializer {
__Ownable_init(_owner);
__Pausable_init();
_initNewtonPolicyClient(_policyTaskManager, _owner);
_setPolicyAddress(_policy);
// domainSeparator still needed for agent EIP-712 signatures
}

What This Means for the Project

This is a dual-layer validation architecture:

Layer 1: Newton Protocol (policy evaluation)
└── BLS-attested Rego policy evaluation
└── Validates: "Is this payment allowed by the operator's policy?"
└── Verified via: _validateAttestation() or _validateAttestationDirect()

Layer 2: Vault-local enforcement (budget guardrails)
└── On-chain daily limit, recipient allowlist, chain restriction
└── Validates: "Does this payment stay within on-chain budget limits?"
└── Verified via: existing periodSpent, allowedRecipients, allowedChainIds logic

Layer 3: Agent authorization (intent signing)
└── EIP-712 agent signature (Turnkey session key)
└── Validates: "Did the agent actually authorize this specific payment?"
└── Verified via: ECDSA.recover on agent signature (unchanged)

The Newton attestation replaces the centralized attestor ECDSA check. The local budget enforcement and agent signature verification stay
as-is — they serve complementary purposes.


This is a meaningful architectural shift. Before I start implementing, I want to align on a few decisions:

  1. Standard vs Direct attestation — _validateAttestation() (cheaper gas, requires aggregator to have already submitted) vs
    _validateAttestationDirect() (more gas, no aggregator wait). The x402 flow likely wants direct for lower latency. Which do you prefer, or
    should the vault support both?
  2. Submodule path — The full newton-prover-avs repo is heavy (EigenLayer, risc0, sp1). An alternative is a newton-contracts repo
    containing just src/ from prover-avs contracts. Does a newton-contracts package/repo exist, or should we submodule the full
    newton-prover-avs?
  3. Scope — Should I plan this as a full refactor (vault + factory + SDK + tests + E2E) in one pass, or start with just the Solidity
    contracts and dependency setup?

@denniswon

Copy link
Copy Markdown
Author

Summary of Changes

★ Insight ─────────────────────────────────────
The fix mirrors the exact validation pattern already used by the secure raiseAndResolveChallenge function. The key architectural lesson: when a
function can't verify against on-chain state (cross-chain tasks don't exist on source), it must verify against the ZK proof's own public values
AND require cryptographic proof of the non-signer set. Both verification layers are necessary — proof binding alone prevents proof replay but not
non-signer manipulation, and certificate verification alone prevents non-signer manipulation but not proof replay.
─────────────────────────────────────────────────

Files Modified (4 files)

┌───────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────┐
│ File │ Change │
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┤
│ contracts/src/interfaces/INewtonProverTaskManager.sol │ Added bytes calldata signatureData parameter │
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┤
│ contracts/src/NewtonProverTaskManagerShared.sol │ Threads signatureData + taskResponseHandler to ChallengeVerifier │
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┤
│ contracts/src/middlewares/ChallengeVerifier.sol │ Core fix: 4 new validation layers in slashForCrossChainChallenge │
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┤
│ crates/challenger/src/dest/relayer.rs │ Passes response_certificate.signatureData in relay call │
└───────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────┘

Vulnerability Closures

┌────────────────────────────────┬───────────────────────────────────────────────────────┬───────────────────────────┐
│ Vector │ Fix │ Line │
├────────────────────────────────┼───────────────────────────────────────────────────────┼───────────────────────────┤
│ Proof-Task disconnection │ require(context.task == task) │ ChallengeVerifier:246-249 │
├────────────────────────────────┼───────────────────────────────────────────────────────┼───────────────────────────┤
│ Proof-Response disconnection │ require(context.taskResponse == taskResponse) │ ChallengeVerifier:250-253 │
├────────────────────────────────┼───────────────────────────────────────────────────────┼───────────────────────────┤
│ Proof-Entrypoint disconnection │ require(policy.getEntrypoint() == context.entrypoint) │ ChallengeVerifier:254-258 │
├────────────────────────────────┼───────────────────────────────────────────────────────┼───────────────────────────┤
│ Unverified non-signer list │ verifyTaskResponse() → validateSignatoryRecord() │ ChallengeVerifier:268-281 │
└────────────────────────────────┴───────────────────────────────────────────────────────┴───────────────────────────┘

Next Steps

  1. Run make generate-bindings to regenerate Rust ABI bindings (the Rust relayer won't compile until this is done)
  2. Run make clippy after bindings regeneration to verify Rust compiles cleanly
  3. Run challenger E2E (make challenger-e2e) to verify the relay path end-to-end
  4. ABI-breaking change notice: This changes the slashForCrossChainChallenge selector (new signatureData param). Any external callers need to
    recompile against the new ABI — same pattern as the initializationTimestamp selector mismatch documented in lessons.md

@denniswon

Copy link
Copy Markdown
Author

Plan complete and saved to docs/superpowers/plans/2026-03-15-newton-policy-client-refactor.md.

Here's the summary of what the plan covers across 5 chunks and 17 tasks:

┌──────────────────────────────┬─────────┬───────────────────────────────────────────────────────────────────────────────────────────┐
│ Chunk │ Tasks │ What it does │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 1: Forge Setup & Vault │ T1-T7 │ Submodule, remappings, via_ir, rewrite vault + factory + interface + tests │
│ Refactor │ │ │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 2: Rego Policy & Data Oracle │ T8-T9 │ Update Rego policies for intent-based eval, create WASM spending oracle, deploy on-chain │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 3: SDK Refactor │ T10-T13 │ Strip AgentPolicy types, integrate @magicnewton/newton-protocol-sdk, rewrite vault.ts + │
│ │ │ agent.ts │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 4: E2E & Downstream │ T14-T15 │ Dual-anvil E2E tests, update CLI/MCP/API/demo │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 5: Docs & Cleanup │ T16-T17 │ Architecture docs, security docs, lessons learned, final verification │
└──────────────────────────────┴─────────┴───────────────────────────────────────────────────────────────────────────────────────────┘

★ Insight ─────────────────────────────────────

This refactor radically simplifies the vault. The current NewtonVault.sol is ~325 lines with agent registration, EIP-712 domain, nonce
tracking, budget enforcement, dual-sig verification. The new vault is ~120 lines: initialize, validate attestation, execute intent, admin
functions. All the complexity moves off-chain to Newton's Rego policy engine and WASM data oracles, where it's more flexible and doesn't
cost gas.

The tradeoff: The vault becomes a generic intent executor — any call the Newton policy approves can be executed. The security guarantee
shifts from "on-chain code enforces rules" to "BLS-staked operator quorum enforces rules, backed by slashable economic stake." This is
the Newton Protocol's core value proposition.

─────────────────────────────────────────────────

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment